Compare commits

...

12 Commits

Author SHA1 Message Date
Jarvis
c39fa065fc fix: simplify updater to @mosaic/mosaic only, add explicit tea repo/login flags (#387)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
BREAKING CHANGE: Remove legacy @mosaic/cli fallback from update-checker.
The updater now targets only @mosaic/mosaic — legacy package compatibility
is out of scope per approved breaking change.

- update-checker.ts: remove LEGACY_PKG, INSTALLED_PACKAGE_ORDER, candidate
  iteration, and cache key comparison. Single query for @mosaic/mosaic only.
- update-checker.test.ts: remove legacy fallback and cross-contamination
  tests. Add test asserting no @mosaic/cli queries are made.
- pr-merge.sh: add --repo and --login flags to tea merge command; resolve
  OWNER/REPO before case block.
- issue-close.sh: add --repo and --login flags to tea issue commands;
  resolve OWNER/REPO after detect_platform.
- pr-ci-wait.sh: no changes needed (uses curl, not tea).
- detect-platform.sh: no changes needed (provides get_repo_owner/name).

Ref: #387
2026-04-04 21:06:59 -05:00
255ba46a4d fix(packages): republish @mosaic/config and bump dependents (#386)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 01:56:57 +00:00
10285933a0 fix: retarget updater to @mosaic/mosaic (#384)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 01:52:30 +00:00
543388e18b fix(mosaic): resolve framework scripts via import.meta.url (#385)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Fixes #383 — resolveTool now uses fileURLToPath(import.meta.url). Adds package.json/framework subpath exports. Bumps @mosaic/mosaic to 0.0.18.
2026-04-05 01:41:46 +00:00
07a1f5d594 Merge pull request 'feat(mosaic): merge @mosaic/cli into @mosaic/mosaic' (#381) from fix/merge-cli-into-mosaic into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 01:11:33 +00:00
Jarvis
c6fc090c98 feat(mosaic): merge @mosaic/cli into @mosaic/mosaic
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
@mosaic/mosaic is now the single package providing both:
- 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.)
- 'mosaic-wizard' binary (installation wizard)

Changes:
- Move packages/cli/src/* into packages/mosaic/src/
- Convert dynamic @mosaic/mosaic imports to static relative imports
- Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic
- Add jsx: react-jsx to mosaic's tsconfig
- Exclude packages/cli from workspace (pnpm-workspace.yaml)
- Update install.sh to install @mosaic/mosaic instead of @mosaic/cli
- Bump version to 0.0.17

This eliminates the circular dependency between @mosaic/cli and
@mosaic/mosaic that was blocking the build graph.
2026-04-04 20:07:27 -05:00
9723b6b948 chore: bump @mosaic/cli and @mosaic/mosaic to 0.0.16 (#379)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 00:52:09 +00:00
c0d0fd44b7 refactor(storage): replace better-sqlite3 with PGlite adapter (#378)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 21:58:14 +00:00
30c0fb1308 fix: remediate npm deprecation warnings in @mosaic/gateway 0.0.3 (#377)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 21:03:54 +00:00
26fac4722f fix: gateway install preserves npm prefix via registry flag (#376)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 20:36:15 +00:00
e3f64c79d9 chore: move gateway default port from 4000 to 14242 (#375)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 20:17:40 +00:00
cbd5e8c626 fix: scope Gitea registry to @mosaic packages only (#374)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 19:09:14 +00:00
87 changed files with 8102 additions and 1453 deletions

View File

@@ -23,8 +23,8 @@ VALKEY_URL=redis://localhost:6380
# ─── Gateway ───────────────────────────────────────────────────────────────── # ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 4000) # TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=4000 GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins. # Comma-separated list of allowed CORS origins.
# Must include the web app origin in production. # Must include the web app origin in production.
@@ -37,12 +37,12 @@ GATEWAY_CORS_ORIGIN=http://localhost:3000
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs) # Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:4000 BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (Next.js) ─────────────────────────────────────────────────────── # ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server. # Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000 NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ─────────────────────────────────────────────────────────── # ─── OpenTelemetry ───────────────────────────────────────────────────────────
@@ -121,12 +121,12 @@ OTEL_SERVICE_NAME=mosaic-gateway
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ───────────── # ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN= # DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID= # DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:4000 # DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ─────────── # ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN= # TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:4000 # TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ─────────────────────────────── # ─── SSO Providers (add credentials to enable) ───────────────────────────────

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/gateway", "name": "@mosaic/gateway",
"version": "0.0.2", "version": "0.0.5",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
@@ -28,8 +28,8 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.80.0", "@anthropic-ai/sdk": "^0.80.0",
"@fastify/helmet": "^13.0.2", "@fastify/helmet": "^13.0.2",
"@mariozechner/pi-ai": "~0.57.1", "@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "~0.57.1", "@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@mosaic/auth": "workspace:^", "@mosaic/auth": "workspace:^",
"@mosaic/brain": "workspace:^", "@mosaic/brain": "workspace:^",
@@ -49,7 +49,7 @@
"@nestjs/platform-socket.io": "^11.0.0", "@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.0.0", "@nestjs/websockets": "^11.0.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0", "@opentelemetry/auto-instrumentations-node": "^0.72.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0", "@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
"@opentelemetry/resources": "^2.6.0", "@opentelemetry/resources": "^2.6.0",

View File

@@ -62,7 +62,7 @@ function restoreEnv(saved: Map<EnvKey, string | undefined>): void {
} }
function makeRegistry(): ModelRegistry { function makeRegistry(): ModelRegistry {
return new ModelRegistry(AuthStorage.inMemory()); return ModelRegistry.inMemory(AuthStorage.inMemory());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -67,7 +67,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
const authStorage = AuthStorage.inMemory(); const authStorage = AuthStorage.inMemory();
this.registry = new ModelRegistry(authStorage); this.registry = ModelRegistry.inMemory(authStorage);
// Build the default set of adapters that rely on the registry // Build the default set of adapters that rely on the registry
this.adapters = [ this.adapters = [

View File

@@ -14,7 +14,7 @@ import { SsoController } from './sso.controller.js';
useFactory: (db: Db): Auth => useFactory: (db: Db): Auth =>
createAuth({ createAuth({
db, db,
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000', baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
secret: process.env['BETTER_AUTH_SECRET'], secret: process.env['BETTER_AUTH_SECRET'],
}), }),
inject: [DB], inject: [DB],

View File

@@ -59,7 +59,7 @@ async function bootstrap(): Promise<void> {
mountAuthHandler(app); mountAuthHandler(app);
mountMcpHandler(app, app.get(McpService)); mountMcpHandler(app, app.get(McpService));
const port = Number(process.env['GATEWAY_PORT'] ?? 4000); const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
await app.listen(port, '0.0.0.0'); await app.listen(port, '0.0.0.0');
logger.log(`Gateway listening on port ${port}`); logger.log(`Gateway listening on port ${port}`);
} }

View File

@@ -48,7 +48,7 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
} }
} }
const DEFAULT_GATEWAY_URL = 'http://localhost:4000'; const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
function createPluginRegistry(): IChannelPlugin[] { function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = []; const plugins: IChannelPlugin[] = [];

View File

@@ -5,7 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
* *
* Assumes: * Assumes:
* - Next.js web app running on http://localhost:3000 * - Next.js web app running on http://localhost:3000
* - NestJS gateway running on http://localhost:4000 * - NestJS gateway running on http://localhost:14242
* *
* Run with: pnpm --filter @mosaic/web test:e2e * Run with: pnpm --filter @mosaic/web test:e2e
*/ */

View File

@@ -1,4 +1,4 @@
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000'; const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
export interface ApiRequestInit extends Omit<RequestInit, 'body'> { export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
body?: unknown; body?: unknown;

View File

@@ -2,7 +2,7 @@ import { createAuthClient } from 'better-auth/react';
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins'; import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000', baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
plugins: [adminClient(), genericOAuthClient()], plugins: [adminClient(), genericOAuthClient()],
}); });

View File

@@ -1,6 +1,6 @@
import { io, type Socket } from 'socket.io-client'; import { io, type Socket } from 'socket.io-client';
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000'; const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
let socket: Socket | null = null; let socket: Socket | null = null;

View File

@@ -93,7 +93,7 @@ packages/cli/src/tui/
cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements
pnpm --filter @mosaic/cli exec tsx src/cli.ts tui pnpm --filter @mosaic/cli exec tsx src/cli.ts tui
# or after build: # or after build:
node packages/cli/dist/cli.js tui --gateway http://localhost:4000 node packages/cli/dist/cli.js tui --gateway http://localhost:14242
``` ```
### Quality Gates ### Quality Gates

View File

@@ -229,11 +229,11 @@ external clients. Authentication requires a valid BetterAuth session (cookie or
### Gateway ### Gateway
| Variable | Default | Description | | Variable | Default | Description |
| --------------------- | ----------------------- | ---------------------------------------------- | | --------------------- | ------------------------ | ---------------------------------------------- |
| `GATEWAY_PORT` | `4000` | Port the gateway listens on | | `GATEWAY_PORT` | `14242` | Port the gateway listens on |
| `GATEWAY_CORS_ORIGIN` | `http://localhost:3000` | Allowed CORS origin for browser clients | | `GATEWAY_CORS_ORIGIN` | `http://localhost:3000` | Allowed CORS origin for browser clients |
| `BETTER_AUTH_URL` | `http://localhost:4000` | Public URL of the gateway (used by BetterAuth) | | `BETTER_AUTH_URL` | `http://localhost:14242` | Public URL of the gateway (used by BetterAuth) |
### SSO (Optional) ### SSO (Optional)
@@ -292,13 +292,13 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Plugins ### Plugins
| Variable | Description | | Variable | Description |
| ---------------------- | ------------------------------------------------------------------------- | | ---------------------- | -------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | | `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_GUILD_ID` | Discord guild/server ID | | `DISCORD_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:4000`) | | `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | | `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | | `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
### Observability ### Observability
@@ -309,9 +309,9 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Web App ### Web App
| Variable | Default | Description | | Variable | Default | Description |
| ------------------------- | ----------------------- | -------------------------------------- | | ------------------------- | ------------------------ | -------------------------------------- |
| `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:4000` | Gateway URL used by the Next.js client | | `NEXT_PUBLIC_GATEWAY_URL` | `http://localhost:14242` | Gateway URL used by the Next.js client |
### Coordination ### Coordination

View File

@@ -194,7 +194,7 @@ server {
# WebSocket support (for chat.gateway.ts / Socket.IO) # WebSocket support (for chat.gateway.ts / Socket.IO)
location /socket.io/ { location /socket.io/ {
proxy_pass http://127.0.0.1:4000; proxy_pass http://127.0.0.1:14242;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -204,7 +204,7 @@ server {
# REST + auth # REST + auth
location / { location / {
proxy_pass http://127.0.0.1:4000; proxy_pass http://127.0.0.1:14242;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -234,11 +234,11 @@ server {
# /etc/caddy/Caddyfile # /etc/caddy/Caddyfile
your-domain.example.com { your-domain.example.com {
reverse_proxy /socket.io/* localhost:4000 { reverse_proxy /socket.io/* localhost:14242 {
header_up Upgrade {http.upgrade} header_up Upgrade {http.upgrade}
header_up Connection {http.connection} header_up Connection {http.connection}
} }
reverse_proxy localhost:4000 reverse_proxy localhost:14242
} }
app.your-domain.example.com { app.your-domain.example.com {
@@ -328,7 +328,7 @@ MaxRetentionSec=30day
- Set `BETTER_AUTH_SECRET` to a cryptographically random value (`openssl rand -base64 32`). - Set `BETTER_AUTH_SECRET` to a cryptographically random value (`openssl rand -base64 32`).
- Restrict `GATEWAY_CORS_ORIGIN` to your exact frontend origin — do not use `*`. - Restrict `GATEWAY_CORS_ORIGIN` to your exact frontend origin — do not use `*`.
- Run services as a dedicated non-root system user (e.g., `mosaic`). - Run services as a dedicated non-root system user (e.g., `mosaic`).
- Firewall: only expose ports 80/443 externally; keep 4000 and 3000 bound to `127.0.0.1`. - Firewall: only expose ports 80/443 externally; keep 14242 and 3000 bound to `127.0.0.1`.
- Set `AGENT_FILE_SANDBOX_DIR` to a directory outside the application root to prevent agent tools from accessing source code. - Set `AGENT_FILE_SANDBOX_DIR` to a directory outside the application root to prevent agent tools from accessing source code.
- If using `AGENT_USER_TOOLS`, enumerate only the tools non-admin users need. - If using `AGENT_USER_TOOLS`, enumerate only the tools non-admin users need.

View File

@@ -112,11 +112,11 @@ DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
BETTER_AUTH_SECRET=change-me-to-a-random-secret BETTER_AUTH_SECRET=change-me-to-a-random-secret
# Gateway # Gateway
GATEWAY_PORT=4000 GATEWAY_PORT=14242
GATEWAY_CORS_ORIGIN=http://localhost:3000 GATEWAY_CORS_ORIGIN=http://localhost:3000
# Web # Web
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000 NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# Optional: Ollama # Optional: Ollama
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
@@ -141,7 +141,7 @@ migrations in production).
pnpm --filter @mosaic/gateway exec tsx src/main.ts pnpm --filter @mosaic/gateway exec tsx src/main.ts
``` ```
The gateway starts on port `4000` by default. The gateway starts on port `14242` by default.
### 6. Start the Web App ### 6. Start the Web App
@@ -395,7 +395,7 @@ directory are defined there.
## API Endpoint Reference ## API Endpoint Reference
All endpoints are served by the gateway at `http://localhost:4000` by default. All endpoints are served by the gateway at `http://localhost:14242` by default.
### Authentication ### Authentication

View File

@@ -16,7 +16,7 @@
### Prerequisites ### Prerequisites
Mosaic Stack requires a running gateway. Your administrator provides the URL Mosaic Stack requires a running gateway. Your administrator provides the URL
(default: `http://localhost:4000`) and creates your account. (default: `http://localhost:14242`) and creates your account.
### Logging In (Web) ### Logging In (Web)
@@ -177,7 +177,7 @@ mosaic --help
### Signing In ### Signing In
```bash ```bash
mosaic login --gateway http://localhost:4000 --email you@example.com mosaic login --gateway http://localhost:14242 --email you@example.com
``` ```
You are prompted for a password if `--password` is not supplied. The session You are prompted for a password if `--password` is not supplied. The session
@@ -191,12 +191,12 @@ mosaic tui
Options: Options:
| Flag | Default | Description | | Flag | Default | Description |
| ----------------------- | ----------------------- | ---------------------------------- | | ----------------------- | ------------------------ | ---------------------------------- |
| `--gateway <url>` | `http://localhost:4000` | Gateway URL | | `--gateway <url>` | `http://localhost:14242` | Gateway URL |
| `--conversation <id>` | — | Resume a specific conversation | | `--conversation <id>` | — | Resume a specific conversation |
| `--model <modelId>` | server default | Model to use (e.g. `llama3.2`) | | `--model <modelId>` | server default | Model to use (e.g. `llama3.2`) |
| `--provider <provider>` | server default | Provider (e.g. `ollama`, `openai`) | | `--provider <provider>` | server default | Provider (e.g. `ollama`, `openai`) |
If no valid session exists you are prompted to sign in before the TUI launches. If no valid session exists you are prompted to sign in before the TUI launches.

View File

@@ -0,0 +1,54 @@
# Scratchpad — issue #387 updater simplification + Gitea wrapper repo context
- Objective: simplify updater behavior to `@mosaic/mosaic` only, and fix Gitea wrapper scripts so merge/CI/issue operations work reliably when tea needs explicit repo/login context.
- Scope:
- `packages/mosaic/src/runtime/update-checker.ts`
- `packages/mosaic/__tests__/update-checker.test.ts`
- any package metadata/version bumps needed
- repo-source git wrappers under `packages/mosaic/framework/tools/git/`
- Constraints:
- Jason approved breaking changes; legacy `@mosaic/cli` support is out of scope.
- Keep changes focused and mergeable.
- Acceptance:
- updater only targets `@mosaic/mosaic`
- wrapper path works on Gitea in this environment without manual repo guessing
- PR merges squash-only after green CI
## Progress
### 2026-04-04
#### Update Checker Simplification (DONE)
- Removed `LEGACY_PKG`, `INSTALLED_PACKAGE_ORDER` constants — only `PKG = '@mosaic/mosaic'` remains
- `getInstalledVersion()` — removed loop over multiple packages, only checks `@mosaic/mosaic`
- `getLatestVersion()` — removed `installedPackage` parameter and candidate iteration; single query for `@mosaic/mosaic`
- `checkForUpdate()` — removed `currentPackage`-based cache key comparison; cache is now package-agnostic
- `RegistryCache` — removed `currentPackage` and `targetPackage` fields
- `formatUpdateNotice()` — removed `targetChanged` branch (package migration notice no longer relevant)
- All legacy fallback/compatibility logic removed
#### Test Updates (DONE)
- Removed legacy `@mosaic/cli` fallback test
- Removed cache cross-contamination test (was testing legacy→modern package transition)
- Added `does not query legacy @mosaic/cli package` test — asserts no `@mosaic/cli` npm commands are issued
- Added `returns empty result when package is not installed` test
- All 8 tests pass ✅
#### Gitea Wrapper Fixes (DONE)
- `pr-merge.sh`:
- Added `OWNER=$(get_repo_owner)` / `REPO=$(get_repo_name)` before case block
- tea merge command now includes `--repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}`
- `issue-close.sh`:
- Added `OWNER=$(get_repo_owner)` / `REPO=$(get_repo_name)` after detect_platform
- Both `tea issue comment` and `tea issue close` now include `--repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"`
- `pr-ci-wait.sh`: No changes needed — uses curl API calls (not tea), already passes owner/repo correctly
- `detect-platform.sh`: No changes needed — provides the `get_repo_owner`/`get_repo_name` functions used by wrappers
#### Verification
- `vitest run` — 8/8 tests pass
- `tsc --noEmit` — no errors in update-checker.ts (pre-existing workspace dep errors unrelated)
- `eslint` — clean, no warnings

View File

@@ -0,0 +1,34 @@
# Scratchpad — updater package target fix (#382)
- Objective: Fix `mosaic update` so modern installs query `@mosaic/mosaic` instead of stale `@mosaic/cli`.
- Scope: updater logic, user-facing update/install hints, tests, package version bump(s).
- Constraints: preserve backward compatibility for older `@mosaic/cli` installs if practical.
- Acceptance:
- fresh installs using `@mosaic/mosaic` report latest correctly
- older installs do not regress unnecessarily
- tests cover package lookup behavior
- release version bumped for changed package(s)
## Decisions
- Prefer `@mosaic/mosaic` when both modern and legacy packages are installed globally.
- For legacy `@mosaic/cli` installs, query `@mosaic/cli` first, then fall back to `@mosaic/mosaic` if the legacy package is not published.
- Share install-target selection from `packages/mosaic` so both the consolidated CLI and the legacy `packages/cli` entrypoint print/install the same package target.
- Extend the update cache to persist the resolved target package as well as the version so cached checks preserve the migration target.
## Validation
- `pnpm install`
- `pnpm --filter @mosaic/mosaic test -- __tests__/update-checker.test.ts`
- `pnpm exec eslint --no-warn-ignored packages/mosaic/src/runtime/update-checker.ts packages/mosaic/src/cli.ts packages/mosaic/src/index.ts packages/mosaic/__tests__/update-checker.test.ts packages/cli/src/cli.ts`
- `pnpm --filter @mosaic/mosaic lint`
- pre-push hooks: `typecheck`, `lint`, `format:check`
## Review
- Manual review of the updater diff caught and fixed a cache regression where fallback results would lose the resolved package target on subsequent cached checks.
## Risks / Notes
- Direct `pnpm --filter @mosaic/mosaic typecheck` and `pnpm --filter @mosaic/cli ...` checks were not representative in this worktree because `packages/cli` is excluded from `pnpm-workspace.yaml` and the standalone package check lacked the built workspace dependency graph.
- The repo's pre-push hooks provided the authoritative validation path here and passed: root `typecheck`, `lint`, and `format:check`.

View File

@@ -1,6 +1,6 @@
{ {
"tier": "local", "tier": "local",
"storage": { "type": "sqlite", "path": ".mosaic/data.db" }, "storage": { "type": "pglite", "dataDir": ".mosaic/storage-pglite" },
"queue": { "type": "local", "dataDir": ".mosaic/queue" }, "queue": { "type": "local", "dataDir": ".mosaic/queue" },
"memory": { "type": "keyword" } "memory": { "type": "keyword" }
} }

View File

@@ -23,10 +23,5 @@
"turbo": "^2.0.0", "turbo": "^2.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^2.0.0" "vitest": "^2.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
]
} }
} }

View File

@@ -35,7 +35,7 @@ export function createAuth(config: AuthConfig) {
provider: 'pg', provider: 'pg',
usePlural: true, usePlural: true,
}), }),
baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000', baseURL: baseURL ?? process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
secret: secret ?? process.env['BETTER_AUTH_SECRET'], secret: secret ?? process.env['BETTER_AUTH_SECRET'],
basePath: '/api/auth', basePath: '/api/auth',
trustedOrigins, trustedOrigins,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/cli", "name": "@mosaic/cli",
"version": "0.0.12", "version": "0.0.17",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",

View File

@@ -33,7 +33,7 @@ registerLaunchCommands(program);
program program
.command('login') .command('login')
.description('Sign in to a Mosaic gateway') .description('Sign in to a Mosaic gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-e, --email <email>', 'Email address') .option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password') .option('-p, --password <password>', 'Password')
.action(async (opts: { gateway: string; email?: string; password?: string }) => { .action(async (opts: { gateway: string; email?: string; password?: string }) => {
@@ -67,7 +67,7 @@ program
program program
.command('tui') .command('tui')
.description('Launch interactive TUI connected to the gateway') .description('Launch interactive TUI connected to the gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-c, --conversation <id>', 'Resume a conversation by ID') .option('-c, --conversation <id>', 'Resume a conversation by ID')
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)') .option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)') .option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
@@ -208,7 +208,7 @@ const sessionsCmd = program.command('sessions').description('Manage active agent
sessionsCmd sessionsCmd
.command('list') .command('list')
.description('List active agent sessions') .description('List active agent sessions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (opts: { gateway: string }) => { .action(async (opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js'); const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway); const auth = await withAuth(opts.gateway);
@@ -243,7 +243,7 @@ sessionsCmd
sessionsCmd sessionsCmd
.command('resume <id>') .command('resume <id>')
.description('Resume an existing agent session in the TUI') .description('Resume an existing agent session in the TUI')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => { .action(async (id: string, opts: { gateway: string }) => {
const { loadSession, validateSession } = await import('./auth.js'); const { loadSession, validateSession } = await import('./auth.js');
@@ -276,7 +276,7 @@ sessionsCmd
sessionsCmd sessionsCmd
.command('destroy <id>') .command('destroy <id>')
.description('Terminate an active agent session') .description('Terminate an active agent session')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => { .action(async (id: string, opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js'); const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway); const auth = await withAuth(opts.gateway);
@@ -314,7 +314,8 @@ program
.description('Check for and install Mosaic CLI updates') .description('Check for and install Mosaic CLI updates')
.option('--check', 'Check only, do not install') .option('--check', 'Check only, do not install')
.action(async (opts: { check?: boolean }) => { .action(async (opts: { check?: boolean }) => {
const { checkForUpdate, formatUpdateNotice } = await import('@mosaic/mosaic'); const { checkForUpdate, formatUpdateNotice, getInstallCommand } =
await import('@mosaic/mosaic');
const { execSync } = await import('node:child_process'); const { execSync } = await import('node:child_process');
console.log('Checking for updates…'); console.log('Checking for updates…');
@@ -344,7 +345,7 @@ program
try { try {
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry // Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
// globally or non-@mosaic deps will 404 against the Gitea registry. // globally or non-@mosaic deps will 404 against the Gitea registry.
execSync('npm install -g @mosaic/cli@latest', { execSync(getInstallCommand(result), {
stdio: 'inherit', stdio: 'inherit',
timeout: 60_000, timeout: 60_000,
}); });

View File

@@ -34,7 +34,7 @@ export function registerAgentCommand(program: Command) {
const cmd = program const cmd = program
.command('agent') .command('agent')
.description('Manage agent configurations') .description('Manage agent configurations')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List all agents') .option('--list', 'List all agents')
.option('--new', 'Create a new agent') .option('--new', 'Create a new agent')
.option('--show <idOrName>', 'Show agent details') .option('--show <idOrName>', 'Show agent details')

View File

@@ -17,7 +17,7 @@ function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; toke
const meta = readMeta(); const meta = readMeta();
return { return {
host: raw.host ?? meta?.host ?? 'localhost', host: raw.host ?? meta?.host ?? 'localhost',
port: parseInt(raw.port, 10) || meta?.port || 4000, port: parseInt(raw.port, 10) || meta?.port || 14242,
token: raw.token ?? meta?.adminToken, token: raw.token ?? meta?.adminToken,
}; };
} }
@@ -28,7 +28,7 @@ export function registerGatewayCommand(program: Command): void {
.description('Manage the Mosaic gateway daemon') .description('Manage the Mosaic gateway daemon')
.helpOption('--help', 'Display help') .helpOption('--help', 'Display help')
.option('-h, --host <host>', 'Gateway host', 'localhost') .option('-h, --host <host>', 'Gateway host', 'localhost')
.option('-p, --port <port>', 'Gateway port', '4000') .option('-p, --port <port>', 'Gateway port', '14242')
.option('-t, --token <token>', 'Admin API token') .option('-t, --token <token>', 'Admin API token')
.action(() => { .action(() => {
gw.outputHelp(); gw.outputHelp();

View File

@@ -211,7 +211,7 @@ const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
export function installGatewayPackage(): void { export function installGatewayPackage(): void {
console.log('Installing @mosaic/gateway from Gitea registry...'); console.log('Installing @mosaic/gateway from Gitea registry...');
execSync(`npm install -g @mosaic/gateway@latest --registry=${GITEA_REGISTRY}`, { execSync(`npm install -g @mosaic/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
stdio: 'inherit', stdio: 'inherit',
timeout: 120_000, timeout: 120_000,
}); });

View File

@@ -67,7 +67,7 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
const tier = tierAnswer === '2' ? 'team' : 'local'; const tier = tierAnswer === '2' ? 'team' : 'local';
const port = const port =
opts.port !== 4000 opts.port !== 14242
? opts.port ? opts.port
: parseInt( : parseInt(
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(), (await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
@@ -121,7 +121,7 @@ async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOp
tier === 'local' tier === 'local'
? { ? {
tier: 'local', tier: 'local',
storage: { type: 'sqlite', path: join(GATEWAY_HOME, 'data.db') }, storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') }, queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
memory: { type: 'keyword' }, memory: { type: 'keyword' },
} }

View File

@@ -40,7 +40,7 @@ export function registerMissionCommand(program: Command) {
const cmd = program const cmd = program
.command('mission') .command('mission')
.description('Manage missions') .description('Manage missions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List all missions') .option('--list', 'List all missions')
.option('--init', 'Create a new mission') .option('--init', 'Create a new mission')
.option('--plan <idOrName>', 'Run PRD wizard for a mission') .option('--plan <idOrName>', 'Run PRD wizard for a mission')
@@ -86,7 +86,7 @@ export function registerMissionCommand(program: Command) {
cmd cmd
.command('task') .command('task')
.description('Manage mission tasks') .description('Manage mission tasks')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List tasks for a mission') .option('--list', 'List tasks for a mission')
.option('--new', 'Create a task') .option('--new', 'Create a task')
.option('--update <taskId>', 'Update a task') .option('--update <taskId>', 'Update a task')

View File

@@ -6,7 +6,7 @@ export function registerPrdyCommand(program: Command) {
const cmd = program const cmd = program
.command('prdy') .command('prdy')
.description('PRD wizard — create and manage Product Requirement Documents') .description('PRD wizard — create and manage Product Requirement Documents')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--init [name]', 'Create a new PRD') .option('--init [name]', 'Create a new PRD')
.option('--update [name]', 'Update an existing PRD') .option('--update [name]', 'Update an existing PRD')
.option('--project <idOrName>', 'Scope to project') .option('--project <idOrName>', 'Scope to project')

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/config", "name": "@mosaic/config",
"version": "0.0.1", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
@@ -27,6 +27,7 @@
"@mosaic/storage": "workspace:^" "@mosaic/storage": "workspace:^"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^9.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^2.0.0" "vitest": "^2.0.0"
}, },

View File

@@ -26,7 +26,7 @@ export interface MosaicConfig {
export const DEFAULT_LOCAL_CONFIG: MosaicConfig = { export const DEFAULT_LOCAL_CONFIG: MosaicConfig = {
tier: 'local', tier: 'local',
storage: { type: 'sqlite', path: '.mosaic/data.db' }, storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' }, queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' }, memory: { type: 'keyword' },
}; };
@@ -43,7 +43,7 @@ export const DEFAULT_TEAM_CONFIG: MosaicConfig = {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const VALID_TIERS = new Set<string>(['local', 'team']); const VALID_TIERS = new Set<string>(['local', 'team']);
const VALID_STORAGE_TYPES = new Set<string>(['postgres', 'sqlite', 'files']); const VALID_STORAGE_TYPES = new Set<string>(['postgres', 'pglite', 'files']);
const VALID_QUEUE_TYPES = new Set<string>(['bullmq', 'local']); const VALID_QUEUE_TYPES = new Set<string>(['bullmq', 'local']);
const VALID_MEMORY_TYPES = new Set<string>(['pgvector', 'sqlite-vec', 'keyword']); const VALID_MEMORY_TYPES = new Set<string>(['pgvector', 'sqlite-vec', 'keyword']);

View File

@@ -1,7 +1,40 @@
import { describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js'; import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js';
import type { UpdateCheckResult } from '../src/runtime/update-checker.js'; import type { UpdateCheckResult } from '../src/runtime/update-checker.js';
const { execSyncMock, cacheFiles } = vi.hoisted(() => ({
execSyncMock: vi.fn(),
cacheFiles: new Map<string, string>(),
}));
vi.mock('node:child_process', () => ({
execSync: execSyncMock,
}));
vi.mock('node:fs', () => ({
existsSync: vi.fn((path: string) => cacheFiles.has(path)),
mkdirSync: vi.fn(),
readFileSync: vi.fn((path: string) => {
const value = cacheFiles.get(path);
if (value === undefined) {
throw new Error(`ENOENT: ${path}`);
}
return value;
}),
writeFileSync: vi.fn((path: string, content: string) => {
cacheFiles.set(path, content);
}),
}));
vi.mock('node:os', () => ({
homedir: vi.fn(() => '/mock-home'),
}));
async function importUpdateChecker() {
vi.resetModules();
return import('../src/runtime/update-checker.js');
}
describe('semverLt', () => { describe('semverLt', () => {
it('returns true when a < b', () => { it('returns true when a < b', () => {
expect(semverLt('0.0.1', '0.0.2')).toBe(true); expect(semverLt('0.0.1', '0.0.2')).toBe(true);
@@ -25,6 +58,11 @@ describe('semverLt', () => {
}); });
describe('formatUpdateNotice', () => { describe('formatUpdateNotice', () => {
beforeEach(() => {
execSyncMock.mockReset();
cacheFiles.clear();
});
it('returns empty string when up to date', () => { it('returns empty string when up to date', () => {
const result: UpdateCheckResult = { const result: UpdateCheckResult = {
current: '1.0.0', current: '1.0.0',
@@ -49,4 +87,83 @@ describe('formatUpdateNotice', () => {
expect(notice).toContain('0.1.0'); expect(notice).toContain('0.1.0');
expect(notice).toContain('Update available'); expect(notice).toContain('Update available');
}); });
it('uses @mosaic/mosaic for installs', async () => {
execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({
dependencies: {
'@mosaic/mosaic': { version: '0.0.19' },
},
});
}
if (command.includes('view @mosaic/mosaic version')) {
return '0.0.20';
}
throw new Error(`Unexpected command: ${command}`);
});
const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
const notice = formatUpdateNotice(result);
expect(result.current).toBe('0.0.19');
expect(result.latest).toBe('0.0.20');
expect(result.currentPackage).toBe('@mosaic/mosaic');
expect(result.targetPackage).toBe('@mosaic/mosaic');
expect(notice).toContain('@mosaic/mosaic@latest');
});
it('does not query legacy @mosaic/cli package', async () => {
execSyncMock.mockImplementation((command: string) => {
if (command.includes('view @mosaic/cli')) {
throw new Error('Should not query @mosaic/cli');
}
if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({
dependencies: {
'@mosaic/mosaic': { version: '0.0.19' },
},
});
}
if (command.includes('view @mosaic/mosaic version')) {
return '0.0.20';
}
throw new Error(`Unexpected command: ${command}`);
});
const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
expect(result.targetPackage).toBe('@mosaic/mosaic');
expect(result.latest).toBe('0.0.20');
// Verify no @mosaic/cli queries were made
const calls = execSyncMock.mock.calls.map((c: any[]) => c[0] as string);
expect(calls.some((c) => c.includes('@mosaic/cli'))).toBe(false);
});
it('returns empty result when package is not installed', async () => {
execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ dependencies: {} });
}
if (command.includes('view @mosaic/mosaic version')) {
return '';
}
throw new Error(`Unexpected command: ${command}`);
});
const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
expect(result.current).toBe('');
expect(result.updateAvailable).toBe(false);
});
}); });

View File

@@ -45,6 +45,8 @@ fi
# Detect platform and close issue # Detect platform and close issue
detect_platform detect_platform
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
@@ -54,9 +56,9 @@ if [[ "$PLATFORM" == "github" ]]; then
echo "Closed GitHub issue #$ISSUE_NUMBER" echo "Closed GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
fi fi
tea issue close "$ISSUE_NUMBER" tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
echo "Closed Gitea issue #$ISSUE_NUMBER" echo "Closed Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"

View File

@@ -89,6 +89,8 @@ if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
fi fi
PLATFORM=$(detect_platform) PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
case "$PLATFORM" in case "$PLATFORM" in
github) github)
@@ -97,8 +99,7 @@ case "$PLATFORM" in
eval "$CMD" eval "$CMD"
;; ;;
gitea) gitea)
# tea pr merge syntax CMD="tea pr merge $PR_NUMBER --style squash --repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}"
CMD="tea pr merge $PR_NUMBER --style squash"
# Delete branch after merge if requested # Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then if [[ "$DELETE_BRANCH" == true ]]; then

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/mosaic", "name": "@mosaic/mosaic",
"version": "0.0.12", "version": "0.0.19",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
@@ -11,6 +11,7 @@
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"bin": { "bin": {
"mosaic": "dist/cli.js",
"mosaic-wizard": "dist/index.js" "mosaic-wizard": "dist/index.js"
}, },
"exports": { "exports": {
@@ -26,6 +27,7 @@
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@mosaic/config": "workspace:*",
"@mosaic/forge": "workspace:*", "@mosaic/forge": "workspace:*",
"@mosaic/macp": "workspace:*", "@mosaic/macp": "workspace:*",
"@mosaic/prdy": "workspace:*", "@mosaic/prdy": "workspace:*",
@@ -33,12 +35,19 @@
"@mosaic/types": "workspace:*", "@mosaic/types": "workspace:*",
"@clack/prompts": "^0.9.1", "@clack/prompts": "^0.9.1",
"commander": "^13.0.0", "commander": "^13.0.0",
"ink": "^5.0.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"react": "^18.3.0",
"socket.io-client": "^4.8.0",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^2.0.0" "vitest": "^2.0.0"
}, },

115
packages/mosaic/src/auth.ts Normal file
View File

@@ -0,0 +1,115 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
const SESSION_DIR = resolve(homedir(), '.mosaic');
const SESSION_FILE = resolve(SESSION_DIR, 'session.json');
interface StoredSession {
gatewayUrl: string;
cookie: string;
userId: string;
email: string;
expiresAt: string;
}
export interface AuthResult {
cookie: string;
userId: string;
email: string;
}
/**
* Sign in to the gateway and return the session cookie.
*/
export async function signIn(
gatewayUrl: string,
email: string,
password: string,
): Promise<AuthResult> {
const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: gatewayUrl },
body: JSON.stringify({ email, password }),
redirect: 'manual',
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Sign-in failed (${res.status}): ${body}`);
}
// Extract set-cookie header
const setCookieHeader = res.headers.getSetCookie?.() ?? [];
const sessionCookie = setCookieHeader
.map((c) => c.split(';')[0]!)
.filter((c) => c.startsWith('better-auth.session_token='))
.join('; ');
if (!sessionCookie) {
throw new Error('No session cookie returned from sign-in');
}
// Parse the response body for user info
const data = (await res.json()) as { user?: { id: string; email: string } };
const userId = data.user?.id ?? 'unknown';
const userEmail = data.user?.email ?? email;
return { cookie: sessionCookie, userId, email: userEmail };
}
/**
* Save session to ~/.mosaic/session.json
*/
export function saveSession(gatewayUrl: string, auth: AuthResult): void {
if (!existsSync(SESSION_DIR)) {
mkdirSync(SESSION_DIR, { recursive: true });
}
const session: StoredSession = {
gatewayUrl,
cookie: auth.cookie,
userId: auth.userId,
email: auth.email,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
};
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
}
/**
* Load a saved session. Returns null if no session, expired, or wrong gateway.
*/
export function loadSession(gatewayUrl: string): AuthResult | null {
if (!existsSync(SESSION_FILE)) return null;
try {
const raw = readFileSync(SESSION_FILE, 'utf-8');
const session = JSON.parse(raw) as StoredSession;
if (session.gatewayUrl !== gatewayUrl) return null;
if (new Date(session.expiresAt) < new Date()) return null;
return {
cookie: session.cookie,
userId: session.userId,
email: session.email,
};
} catch {
return null;
}
}
/**
* Validate that a stored session is still active by hitting get-session.
*/
export async function validateSession(gatewayUrl: string, cookie: string): Promise<boolean> {
try {
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
headers: { Cookie: cookie, Origin: gatewayUrl },
});
return res.ok;
} catch {
return false;
}
}

426
packages/mosaic/src/cli.ts Normal file
View File

@@ -0,0 +1,426 @@
#!/usr/bin/env node
import { createRequire } from 'module';
import { Command } from 'commander';
import { registerQualityRails } from '@mosaic/quality-rails';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerGatewayCommand } from './commands/gateway.js';
import {
backgroundUpdateCheck,
checkForUpdate,
formatUpdateNotice,
getInstallCommand,
} from './runtime/update-checker.js';
import { runWizard } from './wizard.js';
import { ClackPrompter } from './prompter/clack-prompter.js';
import { HeadlessPrompter } from './prompter/headless-prompter.js';
import { createConfigService } from './config/config-service.js';
import { WizardCancelledError } from './errors.js';
import { DEFAULT_MOSAIC_HOME } from './constants.js';
const _require = createRequire(import.meta.url);
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
// Fire-and-forget update check at startup (non-blocking, cached 1h)
try {
backgroundUpdateCheck();
} catch {
// Silently ignore — update check is best-effort
}
const program = new Command();
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
// ─── runtime launchers + framework commands ────────────────────────────
registerLaunchCommands(program);
// ─── login ──────────────────────────────────────────────────────────────
program
.command('login')
.description('Sign in to a Mosaic gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password')
.action(async (opts: { gateway: string; email?: string; password?: string }) => {
const { signIn, saveSession } = await import('./auth.js');
let email = opts.email;
let password = opts.password;
if (!email || !password) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
if (!email) email = await ask('Email: ');
if (!password) password = await ask('Password: ');
rl.close();
}
try {
const auth = await signIn(opts.gateway, email, password);
saveSession(opts.gateway, auth);
console.log(`Signed in as ${auth.email} (${opts.gateway})`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── tui ────────────────────────────────────────────────────────────────
program
.command('tui')
.description('Launch interactive TUI connected to the gateway')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('-c, --conversation <id>', 'Resume a conversation by ID')
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
.option('--agent <idOrName>', 'Connect to a specific agent')
.option('--project <idOrName>', 'Scope session to project')
.action(
async (opts: {
gateway: string;
conversation?: string;
model?: string;
provider?: string;
agent?: string;
project?: string;
}) => {
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
// Try loading saved session
let session = loadSession(opts.gateway);
if (session) {
const valid = await validateSession(opts.gateway, session.cookie);
if (!valid) {
console.log('Session expired. Please sign in again.');
session = null;
}
}
// No valid session — prompt for credentials
if (!session) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> =>
new Promise((resolve) => rl.question(q, resolve));
console.log(`Sign in to ${opts.gateway}`);
const email = await ask('Email: ');
const password = await ask('Password: ');
rl.close();
try {
const auth = await signIn(opts.gateway, email, password);
saveSession(opts.gateway, auth);
session = auth;
console.log(`Signed in as ${auth.email}\n`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
// Resolve agent ID if --agent was passed by name
let agentId: string | undefined;
let agentName: string | undefined;
if (opts.agent) {
try {
const { fetchAgentConfigs } = await import('./tui/gateway-api.js');
const agents = await fetchAgentConfigs(opts.gateway, session.cookie);
const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent);
if (match) {
agentId = match.id;
agentName = match.name;
} else {
console.error(`Agent "${opts.agent}" not found.`);
process.exit(1);
}
} catch (err) {
console.error(
`Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
}
// Resolve project ID if --project was passed by name
let projectId: string | undefined;
if (opts.project) {
try {
const { fetchProjects } = await import('./tui/gateway-api.js');
const projects = await fetchProjects(opts.gateway, session.cookie);
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
if (match) {
projectId = match.id;
} else {
console.error(`Project "${opts.project}" not found.`);
process.exit(1);
}
} catch (err) {
console.error(
`Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
}
// Auto-create a conversation if none was specified
let conversationId = opts.conversation;
if (!conversationId) {
try {
const { createConversation } = await import('./tui/gateway-api.js');
const conv = await createConversation(opts.gateway, session.cookie, {
...(projectId ? { projectId } : {}),
});
conversationId = conv.id;
} catch (err) {
console.error(
`Failed to create conversation: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
}
// Dynamic import to avoid loading React/Ink for other commands
const { render } = await import('ink');
const React = await import('react');
const { TuiApp } = await import('./tui/app.js');
render(
React.createElement(TuiApp, {
gatewayUrl: opts.gateway,
conversationId,
sessionCookie: session.cookie,
initialModel: opts.model,
initialProvider: opts.provider,
agentId,
agentName: agentName ?? undefined,
projectId,
version: CLI_VERSION,
}),
{ exitOnCtrlC: false },
);
},
);
// ─── sessions ───────────────────────────────────────────────────────────
const sessionsCmd = program.command('sessions').description('Manage active agent sessions');
sessionsCmd
.command('list')
.description('List active agent sessions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
const { fetchSessions } = await import('./tui/gateway-api.js');
try {
const result = await fetchSessions(auth.gateway, auth.cookie);
if (result.total === 0) {
console.log('No active sessions.');
return;
}
console.log(`Active sessions (${result.total}):\n`);
for (const s of result.sessions) {
const created = new Date(s.createdAt).toLocaleString();
const durationSec = Math.round(s.durationMs / 1000);
console.log(` ID: ${s.id}`);
console.log(` Model: ${s.provider}/${s.modelId}`);
console.log(` Created: ${created}`);
console.log(` Prompts: ${s.promptCount}`);
console.log(` Duration: ${durationSec}s`);
if (s.channels.length > 0) {
console.log(` Channels: ${s.channels.join(', ')}`);
}
console.log('');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
sessionsCmd
.command('resume <id>')
.description('Resume an existing agent session in the TUI')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => {
const { loadSession, validateSession } = await import('./auth.js');
const session = loadSession(opts.gateway);
if (!session) {
console.error('Not signed in. Run `mosaic login` first.');
process.exit(1);
}
const valid = await validateSession(opts.gateway, session.cookie);
if (!valid) {
console.error('Session expired. Run `mosaic login` again.');
process.exit(1);
}
const { render } = await import('ink');
const React = await import('react');
const { TuiApp } = await import('./tui/app.js');
render(
React.createElement(TuiApp, {
gatewayUrl: opts.gateway,
conversationId: id,
sessionCookie: session.cookie,
version: CLI_VERSION,
}),
);
});
sessionsCmd
.command('destroy <id>')
.description('Terminate an active agent session')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.action(async (id: string, opts: { gateway: string }) => {
const { withAuth } = await import('./commands/with-auth.js');
const auth = await withAuth(opts.gateway);
const { deleteSession } = await import('./tui/gateway-api.js');
try {
await deleteSession(auth.gateway, auth.cookie, id);
console.log(`Session ${id} destroyed.`);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── gateway ──────────────────────────────────────────────────────────
registerGatewayCommand(program);
// ─── agent ─────────────────────────────────────────────────────────────
registerAgentCommand(program);
// ─── mission ───────────────────────────────────────────────────────────
registerMissionCommand(program);
// ─── quality-rails ──────────────────────────────────────────────────────
registerQualityRails(program);
// ─── update ─────────────────────────────────────────────────────────────
program
.command('update')
.description('Check for and install Mosaic CLI updates')
.option('--check', 'Check only, do not install')
.action(async (opts: { check?: boolean }) => {
// checkForUpdate and formatUpdateNotice imported statically above
const { execSync } = await import('node:child_process');
console.log('Checking for updates…');
const result = checkForUpdate({ skipCache: true });
if (!result.latest) {
console.error('Could not reach the Mosaic registry.');
process.exit(1);
}
console.log(` Installed: ${result.current || '(none)'}`);
console.log(` Latest: ${result.latest}`);
if (!result.updateAvailable) {
console.log('\n✔ Up to date.');
return;
}
const notice = formatUpdateNotice(result);
if (notice) console.log(notice);
if (opts.check) {
process.exit(2); // Signal to callers that an update exists
}
console.log('Installing update…');
try {
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
// globally or non-@mosaic deps will 404 against the Gitea registry.
execSync(getInstallCommand(result), {
stdio: 'inherit',
timeout: 60_000,
});
console.log('\n✔ Updated successfully.');
} catch {
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
process.exit(1);
}
});
// ─── wizard ─────────────────────────────────────────────────────────────
program
.command('wizard')
.description('Run the Mosaic installation wizard')
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
.option('--source-dir <path>', 'Source directory for framework files')
.option('--mosaic-home <path>', 'Target config directory')
.option('--name <name>', 'Agent name')
.option('--role <description>', 'Agent role description')
.option('--style <style>', 'Communication style: direct|friendly|formal')
.option('--accessibility <prefs>', 'Accessibility preferences')
.option('--guardrails <rules>', 'Custom guardrails')
.option('--user-name <name>', 'Your name')
.option('--pronouns <pronouns>', 'Your pronouns')
.option('--timezone <tz>', 'Your timezone')
.action(async (opts: Record<string, string | boolean | undefined>) => {
// All wizard imports are now static (see top of file)
try {
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
const configService = createConfigService(mosaicHome, sourceDir);
await runWizard({
mosaicHome,
sourceDir,
prompter,
configService,
cliOverrides: {
soul: {
agentName: opts['name'] as string | undefined,
roleDescription: opts['role'] as string | undefined,
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
accessibility: opts['accessibility'] as string | undefined,
customGuardrails: opts['guardrails'] as string | undefined,
},
user: {
userName: opts['userName'] as string | undefined,
pronouns: opts['pronouns'] as string | undefined,
timezone: opts['timezone'] as string | undefined,
},
},
});
} catch (err) {
if (err instanceof WizardCancelledError) {
console.log('\nWizard cancelled.');
process.exit(0);
}
console.error('Wizard failed:', err);
process.exit(1);
}
});
program.parse();

View File

@@ -0,0 +1,241 @@
import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { selectItem } from './select-dialog.js';
import {
fetchAgentConfigs,
createAgentConfig,
updateAgentConfig,
deleteAgentConfig,
fetchProjects,
fetchProviders,
} from '../tui/gateway-api.js';
import type { AgentConfigInfo } from '../tui/gateway-api.js';
function formatAgent(a: AgentConfigInfo): string {
const sys = a.isSystem ? ' [system]' : '';
return `${a.name}${sys}${a.provider}/${a.model} (${a.status})`;
}
function showAgentDetail(a: AgentConfigInfo) {
console.log(` ID: ${a.id}`);
console.log(` Name: ${a.name}`);
console.log(` Provider: ${a.provider}`);
console.log(` Model: ${a.model}`);
console.log(` Status: ${a.status}`);
console.log(` System: ${a.isSystem ? 'yes' : 'no'}`);
console.log(` Project: ${a.projectId ?? '—'}`);
console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`);
console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`);
console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`);
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
}
export function registerAgentCommand(program: Command) {
const cmd = program
.command('agent')
.description('Manage agent configurations')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List all agents')
.option('--new', 'Create a new agent')
.option('--show <idOrName>', 'Show agent details')
.option('--update <idOrName>', 'Update an agent')
.option('--delete <idOrName>', 'Delete an agent')
.action(
async (opts: {
gateway: string;
list?: boolean;
new?: boolean;
show?: string;
update?: string;
delete?: string;
}) => {
const auth = await withAuth(opts.gateway);
if (opts.list) {
return listAgents(auth.gateway, auth.cookie);
}
if (opts.new) {
return createAgentWizard(auth.gateway, auth.cookie);
}
if (opts.show) {
return showAgent(auth.gateway, auth.cookie, opts.show);
}
if (opts.update) {
return updateAgentWizard(auth.gateway, auth.cookie, opts.update);
}
if (opts.delete) {
return deleteAgent(auth.gateway, auth.cookie, opts.delete);
}
// Default: interactive select
return interactiveSelect(auth.gateway, auth.cookie);
},
);
return cmd;
}
async function resolveAgent(
gateway: string,
cookie: string,
idOrName: string,
): Promise<AgentConfigInfo | undefined> {
const agents = await fetchAgentConfigs(gateway, cookie);
return agents.find((a) => a.id === idOrName || a.name === idOrName);
}
async function listAgents(gateway: string, cookie: string) {
const agents = await fetchAgentConfigs(gateway, cookie);
if (agents.length === 0) {
console.log('No agents found.');
return;
}
console.log(`Agents (${agents.length}):\n`);
for (const a of agents) {
const sys = a.isSystem ? ' [system]' : '';
const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : '';
console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`);
}
}
async function showAgent(gateway: string, cookie: string, idOrName: string) {
const agent = await resolveAgent(gateway, cookie, idOrName);
if (!agent) {
console.error(`Agent "${idOrName}" not found.`);
process.exit(1);
}
showAgentDetail(agent);
}
async function interactiveSelect(gateway: string, cookie: string) {
const agents = await fetchAgentConfigs(gateway, cookie);
const selected = await selectItem(agents, {
message: 'Select an agent:',
render: formatAgent,
emptyMessage: 'No agents found. Create one with `mosaic agent --new`.',
});
if (selected) {
showAgentDetail(selected);
}
}
async function createAgentWizard(gateway: string, cookie: string) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const name = await ask('Agent name: ');
if (!name.trim()) {
console.error('Name is required.');
return;
}
// Project selection
const projects = await fetchProjects(gateway, cookie);
let projectId: string | undefined;
if (projects.length > 0) {
const selected = await selectItem(projects, {
message: 'Assign to project (optional):',
render: (p) => `${p.name} (${p.status})`,
});
if (selected) projectId = selected.id;
}
// Provider / model selection
const providers = await fetchProviders(gateway, cookie);
let provider = 'default';
let model = 'default';
if (providers.length > 0) {
const allModels = providers.flatMap((p) =>
p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })),
);
if (allModels.length > 0) {
const selected = await selectItem(allModels, {
message: 'Select model:',
render: (m) => m.label,
});
if (selected) {
provider = selected.provider;
model = selected.model;
}
}
}
const systemPrompt = await ask('System prompt (optional, press Enter to skip): ');
const agent = await createAgentConfig(gateway, cookie, {
name: name.trim(),
provider,
model,
projectId,
systemPrompt: systemPrompt.trim() || undefined,
});
console.log(`\nAgent "${agent.name}" created (${agent.id}).`);
} finally {
rl.close();
}
}
async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) {
const agent = await resolveAgent(gateway, cookie, idOrName);
if (!agent) {
console.error(`Agent "${idOrName}" not found.`);
process.exit(1);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
console.log(`Updating agent: ${agent.name}\n`);
const name = await ask(`Name [${agent.name}]: `);
const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `);
const updates: Record<string, unknown> = {};
if (name.trim()) updates['name'] = name.trim();
if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim();
if (Object.keys(updates).length === 0) {
console.log('No changes.');
return;
}
const updated = await updateAgentConfig(gateway, cookie, agent.id, updates);
console.log(`\nAgent "${updated.name}" updated.`);
} finally {
rl.close();
}
}
async function deleteAgent(gateway: string, cookie: string, idOrName: string) {
const agent = await resolveAgent(gateway, cookie, idOrName);
if (!agent) {
console.error(`Agent "${idOrName}" not found.`);
process.exit(1);
}
if (agent.isSystem) {
console.error('Cannot delete system agents.');
process.exit(1);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) =>
rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve),
);
rl.close();
if (answer.toLowerCase() !== 'y') {
console.log('Cancelled.');
return;
}
await deleteAgentConfig(gateway, cookie, agent.id);
console.log(`Agent "${agent.name}" deleted.`);
}

View File

@@ -0,0 +1,152 @@
import type { Command } from 'commander';
import {
getDaemonPid,
readMeta,
startDaemon,
stopDaemon,
waitForHealth,
} from './gateway/daemon.js';
interface GatewayParentOpts {
host: string;
port: string;
token?: string;
}
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
const meta = readMeta();
return {
host: raw.host ?? meta?.host ?? 'localhost',
port: parseInt(raw.port, 10) || meta?.port || 14242,
token: raw.token ?? meta?.adminToken,
};
}
export function registerGatewayCommand(program: Command): void {
const gw = program
.command('gateway')
.description('Manage the Mosaic gateway daemon')
.helpOption('--help', 'Display help')
.option('-h, --host <host>', 'Gateway host', 'localhost')
.option('-p, --port <port>', 'Gateway port', '14242')
.option('-t, --token <token>', 'Admin API token')
.action(() => {
gw.outputHelp();
});
// ─── install ────────────────────────────────────────────────────────────
gw.command('install')
.description('Install and configure the gateway daemon')
.option('--skip-install', 'Skip npm package installation (use local build)')
.action(async (cmdOpts: { skipInstall?: boolean }) => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runInstall } = await import('./gateway/install.js');
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
});
// ─── start ──────────────────────────────────────────────────────────────
gw.command('start')
.description('Start the gateway daemon')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
try {
const pid = startDaemon();
console.log(`Gateway started (PID ${pid.toString()})`);
console.log('Waiting for health...');
const healthy = await waitForHealth(opts.host, opts.port);
if (healthy) {
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
} else {
console.warn('Gateway started but health check timed out. Check logs.');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── stop ───────────────────────────────────────────────────────────────
gw.command('stop')
.description('Stop the gateway daemon')
.action(async () => {
try {
await stopDaemon();
console.log('Gateway stopped.');
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── restart ────────────────────────────────────────────────────────────
gw.command('restart')
.description('Restart the gateway daemon')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const pid = getDaemonPid();
if (pid !== null) {
console.log('Stopping gateway...');
await stopDaemon();
}
console.log('Starting gateway...');
try {
const newPid = startDaemon();
console.log(`Gateway started (PID ${newPid.toString()})`);
const healthy = await waitForHealth(opts.host, opts.port);
if (healthy) {
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
} else {
console.warn('Gateway started but health check timed out. Check logs.');
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── status ─────────────────────────────────────────────────────────────
gw.command('status')
.description('Show gateway daemon status and health')
.action(async () => {
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
const { runStatus } = await import('./gateway/status.js');
await runStatus(opts);
});
// ─── config ─────────────────────────────────────────────────────────────
gw.command('config')
.description('View or modify gateway configuration')
.option('--set <KEY=VALUE>', 'Set a configuration value')
.option('--unset <KEY>', 'Remove a configuration key')
.option('--edit', 'Open config in $EDITOR')
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
const { runConfig } = await import('./gateway/config.js');
await runConfig(cmdOpts);
});
// ─── logs ───────────────────────────────────────────────────────────────
gw.command('logs')
.description('View gateway daemon logs')
.option('-f, --follow', 'Follow log output')
.option('-n, --lines <count>', 'Number of lines to show', '50')
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
const { runLogs } = await import('./gateway/logs.js');
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
});
// ─── uninstall ──────────────────────────────────────────────────────────
gw.command('uninstall')
.description('Uninstall the gateway daemon and optionally remove data')
.action(async () => {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall();
});
}

View File

@@ -0,0 +1,143 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
// Keys that should be masked in output
const SECRET_KEYS = new Set([
'BETTER_AUTH_SECRET',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'ZAI_API_KEY',
'OPENROUTER_API_KEY',
'DISCORD_BOT_TOKEN',
'TELEGRAM_BOT_TOKEN',
]);
function maskValue(key: string, value: string): string {
if (SECRET_KEYS.has(key) && value.length > 8) {
return value.slice(0, 4) + '…' + value.slice(-4);
}
return value;
}
function parseEnvFile(): Map<string, string> {
const map = new Map<string, string>();
if (!existsSync(ENV_FILE)) return map;
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
}
return map;
}
function writeEnvFile(entries: Map<string, string>): void {
ensureDirs();
const lines: string[] = [];
for (const [key, value] of entries) {
lines.push(`${key}=${value}`);
}
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
}
interface ConfigOpts {
set?: string;
unset?: string;
edit?: boolean;
}
export async function runConfig(opts: ConfigOpts): Promise<void> {
// Set a value
if (opts.set) {
const eqIdx = opts.set.indexOf('=');
if (eqIdx === -1) {
console.error('Usage: mosaic gateway config --set KEY=VALUE');
process.exit(1);
}
const key = opts.set.slice(0, eqIdx);
const value = opts.set.slice(eqIdx + 1);
const entries = parseEnvFile();
entries.set(key, value);
writeEnvFile(entries);
console.log(`Set ${key}=${maskValue(key, value)}`);
promptRestart();
return;
}
// Unset a value
if (opts.unset) {
const entries = parseEnvFile();
if (!entries.has(opts.unset)) {
console.error(`Key not found: ${opts.unset}`);
process.exit(1);
}
entries.delete(opts.unset);
writeEnvFile(entries);
console.log(`Removed ${opts.unset}`);
promptRestart();
return;
}
// Open in editor
if (opts.edit) {
if (!existsSync(ENV_FILE)) {
console.error(`No config file found at ${ENV_FILE}`);
console.error('Run `mosaic gateway install` first.');
process.exit(1);
}
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
try {
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
promptRestart();
} catch {
console.error('Editor exited with error.');
}
return;
}
// Default: show current config
showConfig();
}
function showConfig(): void {
if (!existsSync(ENV_FILE)) {
console.log('No gateway configuration found.');
console.log('Run `mosaic gateway install` to set up.');
return;
}
const entries = parseEnvFile();
const meta = readMeta();
console.log('Mosaic Gateway Configuration');
console.log('────────────────────────────');
console.log(` Config file: ${ENV_FILE}`);
console.log(` Meta file: ${META_FILE}`);
console.log();
if (entries.size === 0) {
console.log(' (empty)');
return;
}
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
for (const [key, value] of entries) {
const padding = ' '.repeat(maxKeyLen - key.length);
console.log(` ${key}${padding} ${maskValue(key, value)}`);
}
if (meta?.adminToken) {
console.log();
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
}
}
function promptRestart(): void {
if (getDaemonPid() !== null) {
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
}
}

View File

@@ -0,0 +1,245 @@
import { spawn, execSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
openSync,
constants,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
// ─── Paths ──────────────────────────────────────────────────────────────────
export const GATEWAY_HOME = resolve(
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
);
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
export const ENV_FILE = join(GATEWAY_HOME, '.env');
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
// ─── Meta ───────────────────────────────────────────────────────────────────
export interface GatewayMeta {
version: string;
installedAt: string;
entryPoint: string;
adminToken?: string;
host: string;
port: number;
}
export function readMeta(): GatewayMeta | null {
if (!existsSync(META_FILE)) return null;
try {
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
} catch {
return null;
}
}
export function writeMeta(meta: GatewayMeta): void {
ensureDirs();
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
}
// ─── Directories ────────────────────────────────────────────────────────────
export function ensureDirs(): void {
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
}
// ─── PID management ─────────────────────────────────────────────────────────
export function readPid(): number | null {
if (!existsSync(PID_FILE)) return null;
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function isRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function getDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
return isRunning(pid) ? pid : null;
}
// ─── Entry point resolution ─────────────────────────────────────────────────
export function resolveGatewayEntry(): string {
// Check meta.json for custom entry point
const meta = readMeta();
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
return meta.entryPoint;
}
// Try to resolve from globally installed @mosaic/gateway
try {
const req = createRequire(import.meta.url);
const pkgPath = req.resolve('@mosaic/gateway/package.json');
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
if (existsSync(mainEntry)) return mainEntry;
} catch {
// Not installed globally
}
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
}
// ─── Start / Stop / Health ──────────────────────────────────────────────────
export function startDaemon(): number {
const running = getDaemonPid();
if (running !== null) {
throw new Error(`Gateway is already running (PID ${running.toString()})`);
}
ensureDirs();
const entryPoint = resolveGatewayEntry();
// Load env vars from gateway .env
const env: Record<string, string> = { ...process.env } as Record<string, string>;
if (existsSync(ENV_FILE)) {
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
}
}
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
const child = spawn('node', [entryPoint], {
detached: true,
stdio: ['ignore', logFd, logFd],
env,
cwd: GATEWAY_HOME,
});
if (!child.pid) {
throw new Error('Failed to spawn gateway process');
}
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
child.unref();
return child.pid;
}
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
const pid = getDaemonPid();
if (pid === null) {
throw new Error('Gateway is not running');
}
process.kill(pid, 'SIGTERM');
// Poll for exit
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!isRunning(pid)) {
cleanPidFile();
return;
}
await sleep(250);
}
// Force kill
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already dead
}
cleanPidFile();
}
function cleanPidFile(): void {
try {
unlinkSync(PID_FILE);
} catch {
// Ignore
}
}
export async function waitForHealth(
host: string,
port: number,
timeoutMs = 30_000,
): Promise<boolean> {
const start = Date.now();
let delay = 500;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`http://${host}:${port.toString()}/health`);
if (res.ok) return true;
} catch {
// Not ready yet
}
await sleep(delay);
delay = Math.min(delay * 1.5, 3000);
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── npm install helper ─────────────────────────────────────────────────────
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
export function installGatewayPackage(): void {
console.log('Installing @mosaic/gateway from Gitea registry...');
execSync(`npm install -g @mosaic/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
stdio: 'inherit',
timeout: 120_000,
});
}
export function uninstallGatewayPackage(): void {
try {
execSync('npm uninstall -g @mosaic/gateway', {
stdio: 'inherit',
timeout: 60_000,
});
} catch {
console.warn('Warning: npm uninstall may not have completed cleanly.');
}
}
export function getInstalledGatewayVersion(): string | null {
try {
const output = execSync('npm ls -g @mosaic/gateway --json --depth=0', {
encoding: 'utf-8',
timeout: 15_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const data = JSON.parse(output) as {
dependencies?: { '@mosaic/gateway'?: { version?: string } };
};
return data.dependencies?.['@mosaic/gateway']?.version ?? null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,259 @@
import { randomBytes } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import type { GatewayMeta } from './daemon.js';
import {
ENV_FILE,
GATEWAY_HOME,
ensureDirs,
installGatewayPackage,
readMeta,
resolveGatewayEntry,
startDaemon,
waitForHealth,
writeMeta,
getInstalledGatewayVersion,
} from './daemon.js';
interface InstallOpts {
host: string;
port: number;
skipInstall?: boolean;
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
export async function runInstall(opts: InstallOpts): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doInstall(rl, opts);
} finally {
rl.close();
}
}
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
// Check existing installation
const existing = readMeta();
if (existing) {
const answer = await prompt(
rl,
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
);
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
}
// Step 1: Install npm package
if (!opts.skipInstall) {
installGatewayPackage();
}
ensureDirs();
// Step 2: Collect configuration
console.log('\n─── Gateway Configuration ───\n');
// Tier selection
console.log('Storage tier:');
console.log(' 1. Local (embedded database, no dependencies)');
console.log(' 2. Team (PostgreSQL + Valkey required)');
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
const tier = tierAnswer === '2' ? 'team' : 'local';
const port =
opts.port !== 14242
? opts.port
: parseInt(
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
10,
);
let databaseUrl: string | undefined;
let valkeyUrl: string | undefined;
if (tier === 'team') {
databaseUrl =
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
valkeyUrl =
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
}
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
const corsOrigin =
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
// Generate auth secret
const authSecret = randomBytes(32).toString('hex');
// Step 3: Write .env
const envLines = [
`GATEWAY_PORT=${port.toString()}`,
`BETTER_AUTH_SECRET=${authSecret}`,
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
`OTEL_SERVICE_NAME=mosaic-gateway`,
];
if (tier === 'team' && databaseUrl && valkeyUrl) {
envLines.push(`DATABASE_URL=${databaseUrl}`);
envLines.push(`VALKEY_URL=${valkeyUrl}`);
}
if (anthropicKey) {
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
}
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
console.log(`\nConfig written to ${ENV_FILE}`);
// Step 3b: Write mosaic.config.json
const mosaicConfig =
tier === 'local'
? {
tier: 'local',
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
memory: { type: 'keyword' },
}
: {
tier: 'team',
storage: { type: 'postgres', url: databaseUrl },
queue: { type: 'bullmq', url: valkeyUrl },
memory: { type: 'pgvector' },
};
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
console.log(`Config written to ${configFile}`);
// Step 4: Write meta.json
let entryPoint: string;
try {
entryPoint = resolveGatewayEntry();
} catch {
console.error('Error: Gateway package not found after install.');
console.error('Check that @mosaic/gateway installed correctly.');
return;
}
const version = getInstalledGatewayVersion() ?? 'unknown';
const meta = {
version,
installedAt: new Date().toISOString(),
entryPoint,
host: opts.host,
port,
};
writeMeta(meta);
// Step 5: Start the daemon
console.log('\nStarting gateway daemon...');
try {
const pid = startDaemon();
console.log(`Gateway started (PID ${pid.toString()})`);
} catch (err) {
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// Step 6: Wait for health
console.log('Waiting for gateway to become healthy...');
const healthy = await waitForHealth(opts.host, port, 30_000);
if (!healthy) {
console.error('Gateway did not become healthy within 30 seconds.');
console.error(`Check logs: mosaic gateway logs`);
return;
}
console.log('Gateway is healthy.\n');
// Step 7: Bootstrap — first user setup
await bootstrapFirstUser(rl, opts.host, port, meta);
console.log('\n─── Installation Complete ───');
console.log(` Endpoint: http://${opts.host}:${port.toString()}`);
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: mosaic gateway logs`);
console.log(` Status: mosaic gateway status`);
}
async function bootstrapFirstUser(
rl: ReturnType<typeof createInterface>,
host: string,
port: number,
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
): Promise<void> {
const baseUrl = `http://${host}:${port.toString()}`;
try {
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
if (!statusRes.ok) return;
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
console.log('Admin user already exists — skipping setup.');
return;
}
} catch {
console.warn('Could not check bootstrap status — skipping first user setup.');
return;
}
console.log('─── Admin User Setup ───\n');
const name = (await prompt(rl, 'Admin name: ')).trim();
if (!name) {
console.error('Name is required.');
return;
}
const email = (await prompt(rl, 'Admin email: ')).trim();
if (!email) {
console.error('Email is required.');
return;
}
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
if (password.length < 8) {
console.error('Password must be at least 8 characters.');
return;
}
try {
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
return;
}
const result = (await res.json()) as {
user: { id: string; email: string };
token: { plaintext: string };
};
// Save admin token to meta
meta.adminToken = result.token.plaintext;
writeMeta(meta as GatewayMeta);
console.log(`\nAdmin user created: ${result.user.email}`);
console.log('Admin API token saved to gateway config.');
} catch (err) {
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
}
}

View File

@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { LOG_FILE } from './daemon.js';
interface LogsOpts {
follow?: boolean;
lines?: number;
}
export function runLogs(opts: LogsOpts): void {
if (!existsSync(LOG_FILE)) {
console.log('No log file found. Is the gateway installed?');
return;
}
if (opts.follow) {
const lines = opts.lines ?? 50;
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
stdio: 'inherit',
});
tail.on('error', () => {
// Fallback for systems without tail
console.log(readLastLines(opts.lines ?? 50));
console.log('\n(--follow requires `tail` command)');
});
return;
}
// Just print last N lines
console.log(readLastLines(opts.lines ?? 50));
}
function readLastLines(n: number): string {
const content = readFileSync(LOG_FILE, 'utf-8');
const lines = content.split('\n');
return lines.slice(-n).join('\n');
}

View File

@@ -0,0 +1,115 @@
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
interface GatewayOpts {
host: string;
port: number;
token?: string;
}
interface ServiceStatus {
name: string;
status: string;
latency?: string;
}
interface AdminHealth {
status: string;
services: {
database: { status: string; latencyMs: number };
cache: { status: string; latencyMs: number };
};
agentPool?: { active: number };
providers?: Array<{ name: string; available: boolean; models: number }>;
}
export async function runStatus(opts: GatewayOpts): Promise<void> {
const meta = readMeta();
const pid = getDaemonPid();
console.log('Mosaic Gateway Status');
console.log('─────────────────────');
// Daemon status
if (pid !== null) {
console.log(` Status: running (PID ${pid.toString()})`);
} else {
console.log(' Status: stopped');
}
// Version
console.log(` Version: ${meta?.version ?? 'unknown'}`);
// Endpoint
const host = opts.host;
const port = opts.port;
console.log(` Endpoint: http://${host}:${port.toString()}`);
console.log(` Config: ${GATEWAY_HOME}`);
console.log(` Logs: ${LOG_FILE}`);
if (pid === null) return;
// Health check
try {
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
if (!healthRes.ok) {
console.log('\n Health: unreachable');
return;
}
} catch {
console.log('\n Health: unreachable');
return;
}
// Admin health (requires token)
const token = opts.token ?? meta?.adminToken;
if (!token) {
console.log(
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
);
return;
}
try {
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
console.log('\n Admin health: unauthorized or unavailable');
return;
}
const health = (await res.json()) as AdminHealth;
console.log('\n Services:');
const services: ServiceStatus[] = [
{
name: 'Database',
status: health.services.database.status,
latency: `${health.services.database.latencyMs.toString()}ms`,
},
{
name: 'Cache',
status: health.services.cache.status,
latency: `${health.services.cache.latencyMs.toString()}ms`,
},
];
for (const svc of services) {
const latStr = svc.latency ? ` (${svc.latency})` : '';
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
}
if (health.providers && health.providers.length > 0) {
const available = health.providers.filter((p) => p.available);
const names = available.map((p) => p.name).join(', ');
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
}
if (health.agentPool) {
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
}
} catch {
console.log('\n Admin health: connection error');
}
}

View File

@@ -0,0 +1,62 @@
import { existsSync, rmSync } from 'node:fs';
import { createInterface } from 'node:readline';
import {
GATEWAY_HOME,
getDaemonPid,
readMeta,
stopDaemon,
uninstallGatewayPackage,
} from './daemon.js';
export async function runUninstall(): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl);
} finally {
rl.close();
}
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
return;
}
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
// Stop if running
if (getDaemonPid() !== null) {
console.log('Stopping gateway daemon...');
try {
await stopDaemon();
console.log('Stopped.');
} catch (err) {
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
}
}
// Remove config/data
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
if (removeData.toLowerCase() === 'y') {
if (existsSync(GATEWAY_HOME)) {
rmSync(GATEWAY_HOME, { recursive: true, force: true });
console.log('Gateway data removed.');
}
}
// Uninstall npm package
console.log('Uninstalling npm package...');
uninstallGatewayPackage();
console.log('\nGateway uninstalled.');
}

View File

@@ -0,0 +1,772 @@
/**
* Native runtime launcher — replaces the bash mosaic-launch script.
*
* Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md +
* TOOLS.md + mission context + PRD status, then exec's into the target CLI.
*/
import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
const RUNTIME_LABELS: Record<RuntimeName, string> = {
claude: 'Claude Code',
codex: 'Codex',
opencode: 'OpenCode',
pi: 'Pi',
};
// ─── Pre-flight checks ──────────────────────────────────────────────────────
function checkMosaicHome(): void {
if (!existsSync(MOSAIC_HOME)) {
console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`);
console.error(
'[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)',
);
process.exit(1);
}
}
function checkFile(path: string, label: string): void {
if (!existsSync(path)) {
console.error(`[mosaic] ERROR: ${label} not found: ${path}`);
process.exit(1);
}
}
function checkRuntime(cmd: string): void {
try {
execSync(`which ${cmd}`, { stdio: 'ignore' });
} catch {
console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`);
console.error(`[mosaic] Install ${cmd} before launching.`);
process.exit(1);
}
}
function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
// Prefer the TypeScript wizard (idempotent, detects existing files)
try {
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
stdio: 'inherit',
});
if (result.status === 0 && existsSync(soulPath)) return;
} catch {
// Fall through to legacy init
}
// Fallback: legacy bash mosaic-init
const initBin = fwScript('mosaic-init');
if (existsSync(initBin)) {
spawnSync(initBin, [], { stdio: 'inherit' });
} else {
console.error('[mosaic] Setup failed. Run: mosaic wizard');
process.exit(1);
}
}
}
function checkSequentialThinking(runtime: string): void {
const checker = fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
process.exit(1);
}
}
// ─── File helpers ────────────────────────────────────────────────────────────
function readOptional(path: string): string {
try {
return readFileSync(path, 'utf-8');
} catch {
return '';
}
}
function readJson(path: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
}
// ─── Mission context ─────────────────────────────────────────────────────────
interface MissionInfo {
name: string;
id: string;
status: string;
milestoneCount: number;
completedCount: number;
}
function detectMission(): MissionInfo | null {
const missionFile = '.mosaic/orchestrator/mission.json';
const data = readJson(missionFile);
if (!data) return null;
const status = String(data['status'] ?? 'inactive');
if (status !== 'active' && status !== 'paused') return null;
const milestones = Array.isArray(data['milestones']) ? data['milestones'] : [];
const completed = milestones.filter(
(m) =>
typeof m === 'object' &&
m !== null &&
(m as Record<string, unknown>)['status'] === 'completed',
);
return {
name: String(data['name'] ?? 'unnamed'),
id: String(data['mission_id'] ?? ''),
status,
milestoneCount: milestones.length,
completedCount: completed.length,
};
}
function buildMissionBlock(mission: MissionInfo): string {
return `# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
An active orchestration mission exists in this project. This is a BLOCKING requirement.
**Mission:** ${mission.name}
**ID:** ${mission.id}
**Status:** ${mission.status}
**Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed
## MANDATORY — Before ANY Response to the User
You MUST complete these steps before responding to any user message, including simple greetings:
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
5. After reading all four, acknowledge the mission state to the user before proceeding
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
`;
}
// ─── PRD status ──────────────────────────────────────────────────────────────
function buildPrdBlock(): string {
const prdFile = 'docs/PRD.md';
if (!existsSync(prdFile)) return '';
const content = readFileSync(prdFile, 'utf-8');
const patterns = [
/^#{2,3} .*(problem statement|objective)/im,
/^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im,
/^#{2,3} .*(user stor|stakeholder|user.*requirement)/im,
/^#{2,3} .*functional requirement/im,
/^#{2,3} .*non.functional/im,
/^#{2,3} .*acceptance criteria/im,
/^#{2,3} .*(technical consideration|constraint|dependenc)/im,
/^#{2,3} .*(risk|open question)/im,
/^#{2,3} .*(success metric|test|verification)/im,
/^#{2,3} .*(milestone|delivery|scope version)/im,
];
let sections = 0;
for (const pattern of patterns) {
if (pattern.test(content)) sections++;
}
const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length;
const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready';
return `
# PRD Status
- **File:** docs/PRD.md
- **Status:** ${status}
- **Assumptions:** ${assumptions}
`;
}
// ─── Runtime prompt builder ──────────────────────────────────────────────────
function buildRuntimePrompt(runtime: RuntimeName): string {
const runtimeContractPaths: Record<RuntimeName, string> = {
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
};
const runtimeFile = runtimeContractPaths[runtime];
checkFile(runtimeFile, `Runtime contract for ${runtime}`);
const parts: string[] = [];
// Mission context (injected first)
const mission = detectMission();
if (mission) {
parts.push(buildMissionBlock(mission));
}
// PRD status
const prdBlock = buildPrdBlock();
if (prdBlock) parts.push(prdBlock);
// Hard gate
parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate)
This contract is injected by \`mosaic\` launch and is mandatory.
First assistant response MUST start with exactly one mode declaration line:
1. Orchestration mission: \`Now initiating Orchestrator mode...\`
2. Implementation mission: \`Now initiating Delivery mode...\`
3. Review-only mission: \`Now initiating Review mode...\`
No tool call or implementation step may occur before that first line.
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
`);
// AGENTS.md
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
// USER.md
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
if (user) parts.push('\n\n# User Profile\n\n' + user);
// TOOLS.md
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
// Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
return parts.join('\n');
}
// ─── Session lock ────────────────────────────────────────────────────────────
function writeSessionLock(runtime: string): void {
const missionFile = '.mosaic/orchestrator/mission.json';
const lockFile = '.mosaic/orchestrator/session.lock';
const data = readJson(missionFile);
if (!data) return;
const status = String(data['status'] ?? 'inactive');
if (status !== 'active' && status !== 'paused') return;
const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
const lock = {
session_id: sessionId,
runtime,
pid: process.pid,
started_at: new Date().toISOString(),
project_path: process.cwd(),
milestone_id: '',
};
try {
mkdirSync(dirname(lockFile), { recursive: true });
writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n');
// Clean up on exit
const cleanup = () => {
try {
rmSync(lockFile, { force: true });
} catch {
// best-effort
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(130);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(143);
});
} catch {
// Non-fatal
}
}
// ─── Resumable session advisory ──────────────────────────────────────────────
function checkResumableSession(): void {
const lockFile = '.mosaic/orchestrator/session.lock';
const missionFile = '.mosaic/orchestrator/mission.json';
if (existsSync(lockFile)) {
const lock = readJson(lockFile);
if (lock) {
const pid = Number(lock['pid'] ?? 0);
if (pid > 0) {
try {
process.kill(pid, 0); // Check if alive
} catch {
// Process is dead — stale lock
rmSync(lockFile, { force: true });
console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`);
}
}
}
} else if (existsSync(missionFile)) {
const data = readJson(missionFile);
if (data && data['status'] === 'active') {
console.log('[mosaic] Active mission detected. Generate continuation prompt with:');
console.log('[mosaic] mosaic coord continue\n');
}
}
}
// ─── Write config for runtimes that read from fixed paths ────────────────────
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
const prompt = buildRuntimePrompt(runtime);
mkdirSync(dirname(destPath), { recursive: true });
const existing = readOptional(destPath);
if (existing !== prompt) {
writeFileSync(destPath, prompt);
}
}
// ─── Pi skill/extension discovery ────────────────────────────────────────────
function discoverPiSkills(): string[] {
const args: string[] = [];
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
if (!existsSync(skillsRoot)) continue;
try {
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillDir = join(skillsRoot, entry.name);
if (existsSync(join(skillDir, 'SKILL.md'))) {
args.push('--skill', skillDir);
}
}
} catch {
// skip
}
}
return args;
}
function discoverPiExtension(): string[] {
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
return existsSync(ext) ? ['--extension', ext] : [];
}
// ─── Launch functions ────────────────────────────────────────────────────────
function getMissionPrompt(): string {
const mission = detectMission();
if (!mission) return '';
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
}
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkRuntime(runtime);
// Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') {
checkSequentialThinking(runtime);
}
checkResumableSession();
const missionPrompt = getMissionPrompt();
const hasMissionNoArgs = missionPrompt && args.length === 0;
const label = RUNTIME_LABELS[runtime];
const modeStr = yolo ? ' in YOLO mode' : '';
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
writeSessionLock(runtime);
switch (runtime) {
case 'claude': {
const prompt = buildRuntimePrompt('claude');
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('claude', cliArgs);
break;
}
case 'codex': {
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('codex', cliArgs);
break;
}
case 'opencode': {
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
console.log(`[mosaic] Launching ${label}${modeStr}...`);
execRuntime('opencode', args);
break;
}
case 'pi': {
const prompt = buildRuntimePrompt('pi');
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...discoverPiSkills());
cliArgs.push(...discoverPiExtension());
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('pi', cliArgs);
break;
}
}
process.exit(0); // Unreachable but satisfies never
}
/** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[]): void {
try {
// Use execFileSync with inherited stdio to replace the process
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 0);
} catch (err) {
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
process.exit(1);
}
}
// ─── Framework script/tool delegation ───────────────────────────────────────
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
if (!existsSync(scriptPath)) {
console.error(`[mosaic] Script not found: ${scriptPath}`);
process.exit(1);
}
try {
execFileSync('bash', [scriptPath, ...args], {
stdio: 'inherit',
env: { ...process.env, ...env },
});
process.exit(0);
} catch (err) {
process.exit((err as { status?: number }).status ?? 1);
}
}
/**
* Resolve a path under the framework tools directory. Prefers the version
* bundled in the @mosaic/mosaic npm package (always matches the installed
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
*/
function resolveTool(...segments: string[]): string {
try {
const req = createRequire(import.meta.url);
const mosaicPkg = dirname(req.resolve('@mosaic/mosaic/package.json'));
const bundled = join(mosaicPkg, 'framework', 'tools', ...segments);
if (existsSync(bundled)) return bundled;
} catch {
// Fall through to deployed copy
}
return join(MOSAIC_HOME, 'tools', ...segments);
}
function fwScript(name: string): string {
return resolveTool('_scripts', name);
}
function toolScript(toolDir: string, name: string): string {
return resolveTool(toolDir, name);
}
// ─── Coord (mission orchestrator) ───────────────────────────────────────────
const COORD_SUBCMDS: Record<string, string> = {
status: 'session-status.sh',
session: 'session-status.sh',
init: 'mission-init.sh',
mission: 'mission-status.sh',
progress: 'mission-status.sh',
continue: 'continue-prompt.sh',
next: 'continue-prompt.sh',
run: 'session-run.sh',
start: 'session-run.sh',
smoke: 'smoke-test.sh',
test: 'smoke-test.sh',
resume: 'session-resume.sh',
recover: 'session-resume.sh',
};
function runCoord(args: string[]): never {
checkMosaicHome();
let runtime = 'claude';
let yoloFlag = '';
const coordArgs: string[] = [];
for (const arg of args) {
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
runtime = arg.slice(2);
} else if (arg === '--yolo') {
yoloFlag = '--yolo';
} else {
coordArgs.push(arg);
}
}
const subcmd = coordArgs[0] ?? 'help';
const subArgs = coordArgs.slice(1);
const script = COORD_SUBCMDS[subcmd];
if (!script) {
console.log(`mosaic coord — mission coordinator tools
Commands:
init --name <name> [opts] Initialize a new mission
mission [--project <path>] Show mission progress dashboard
status [--project <path>] Check agent session health
continue [--project <path>] Generate continuation prompt
run [--project <path>] Launch runtime with mission context
smoke Run orchestration smoke checks
resume [--project <path>] Crash recovery
Runtime: --claude (default) | --codex | --pi | --yolo`);
process.exit(subcmd === 'help' ? 0 : 1);
}
if (yoloFlag) subArgs.unshift(yoloFlag);
delegateToScript(toolScript('orchestrator', script), subArgs, {
MOSAIC_COORD_RUNTIME: runtime,
});
}
// ─── Prdy (PRD tools via framework scripts) ─────────────────────────────────
const PRDY_SUBCMDS: Record<string, string> = {
init: 'prdy-init.sh',
update: 'prdy-update.sh',
validate: 'prdy-validate.sh',
check: 'prdy-validate.sh',
status: 'prdy-status.sh',
};
function runPrdyLocal(args: string[]): never {
checkMosaicHome();
let runtime = 'claude';
const prdyArgs: string[] = [];
for (const arg of args) {
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
runtime = arg.slice(2);
} else {
prdyArgs.push(arg);
}
}
const subcmd = prdyArgs[0] ?? 'help';
const subArgs = prdyArgs.slice(1);
const script = PRDY_SUBCMDS[subcmd];
if (!script) {
console.log(`mosaic prdy — PRD creation and validation
Commands:
init [--project <path>] [--name <feature>] Create docs/PRD.md
update [--project <path>] Update existing PRD
validate [--project <path>] Check PRD completeness
status [--project <path>] Quick PRD health check
Runtime: --claude (default) | --codex | --pi`);
process.exit(subcmd === 'help' ? 0 : 1);
}
delegateToScript(toolScript('prdy', script), subArgs, {
MOSAIC_PRDY_RUNTIME: runtime,
});
}
// ─── Seq (sequential-thinking MCP) ──────────────────────────────────────────
function runSeq(args: string[]): never {
checkMosaicHome();
const action = args[0] ?? 'check';
const rest = args.slice(1);
const checker = fwScript('mosaic-ensure-sequential-thinking');
switch (action) {
case 'check':
delegateToScript(checker, ['--check', ...rest]);
break; // unreachable
case 'fix':
case 'apply':
delegateToScript(checker, rest);
break;
case 'start': {
console.log('[mosaic] Starting sequential-thinking MCP server...');
try {
execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], {
stdio: 'inherit',
});
process.exit(0);
} catch (err) {
process.exit((err as { status?: number }).status ?? 1);
}
break;
}
default:
console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`);
process.exit(1);
}
}
// ─── Upgrade ────────────────────────────────────────────────────────────────
function runUpgrade(args: string[]): never {
checkMosaicHome();
const subcmd = args[0];
if (!subcmd || subcmd === 'release') {
delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0));
} else if (subcmd === 'check') {
delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]);
} else if (subcmd === 'project') {
delegateToScript(fwScript('mosaic-upgrade'), args.slice(1));
} else if (subcmd.startsWith('-')) {
delegateToScript(fwScript('mosaic-release-upgrade'), args);
} else {
delegateToScript(fwScript('mosaic-upgrade'), args);
}
}
// ─── Commander registration ─────────────────────────────────────────────────
export function registerLaunchCommands(program: Command): void {
// Runtime launchers
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
program
.command(runtime)
.description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
launchRuntime(runtime, cmd.args, false);
});
}
// Yolo mode
program
.command('yolo <runtime>')
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((runtime: string, _opts: unknown, cmd: Command) => {
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
if (!valid.includes(runtime as RuntimeName)) {
console.error(
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
);
process.exit(1);
}
launchRuntime(runtime as RuntimeName, cmd.args, true);
});
// Coord (mission orchestrator)
program
.command('coord')
.description('Mission coordinator tools (init, status, run, continue, resume)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runCoord(cmd.args);
});
// Prdy (PRD tools via local framework scripts)
program
.command('prdy')
.description('PRD creation and validation (init, update, validate, status)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runPrdyLocal(cmd.args);
});
// Seq (sequential-thinking MCP management)
program
.command('seq')
.description('sequential-thinking MCP management (check/fix/start)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runSeq(cmd.args);
});
// Upgrade (release + project)
program
.command('upgrade')
.description('Upgrade Mosaic release or project files')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runUpgrade(cmd.args);
});
// Direct framework script delegates
const directCommands: Record<string, { desc: string; script: string }> = {
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
bootstrap: {
desc: 'Bootstrap a repo with Mosaic standards',
script: 'mosaic-bootstrap-repo',
},
};
for (const [name, { desc, script }] of Object.entries(directCommands)) {
program
.command(name)
.description(desc)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
checkMosaicHome();
delegateToScript(fwScript(script), cmd.args);
});
}
}

View File

@@ -0,0 +1,385 @@
import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { selectItem } from './select-dialog.js';
import {
fetchMissions,
fetchMission,
createMission,
updateMission,
fetchMissionTasks,
createMissionTask,
updateMissionTask,
fetchProjects,
} from '../tui/gateway-api.js';
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
function formatMission(m: MissionInfo): string {
return `${m.name}${m.status}${m.phase ? ` (${m.phase})` : ''}`;
}
function showMissionDetail(m: MissionInfo) {
console.log(` ID: ${m.id}`);
console.log(` Name: ${m.name}`);
console.log(` Status: ${m.status}`);
console.log(` Phase: ${m.phase ?? '—'}`);
console.log(` Project: ${m.projectId ?? '—'}`);
console.log(` Description: ${m.description ?? '—'}`);
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
}
function showTaskDetail(t: MissionTaskInfo) {
console.log(` ID: ${t.id}`);
console.log(` Status: ${t.status}`);
console.log(` Description: ${t.description ?? '—'}`);
console.log(` Notes: ${t.notes ?? '—'}`);
console.log(` PR: ${t.pr ?? '—'}`);
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
}
export function registerMissionCommand(program: Command) {
const cmd = program
.command('mission')
.description('Manage missions')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List all missions')
.option('--init', 'Create a new mission')
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
.option('--update <idOrName>', 'Update a mission')
.option('--project <idOrName>', 'Scope to project')
.argument('[id]', 'Show mission detail by ID')
.action(
async (
id: string | undefined,
opts: {
gateway: string;
list?: boolean;
init?: boolean;
plan?: string;
update?: string;
project?: string;
},
) => {
const auth = await withAuth(opts.gateway);
if (opts.list) {
return listMissions(auth.gateway, auth.cookie);
}
if (opts.init) {
return initMission(auth.gateway, auth.cookie);
}
if (opts.plan) {
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
}
if (opts.update) {
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
}
if (id) {
return showMission(auth.gateway, auth.cookie, id);
}
// Default: interactive select
return interactiveSelect(auth.gateway, auth.cookie);
},
);
// Task subcommand
cmd
.command('task')
.description('Manage mission tasks')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--list', 'List tasks for a mission')
.option('--new', 'Create a task')
.option('--update <taskId>', 'Update a task')
.option('--mission <idOrName>', 'Mission ID or name')
.argument('[taskId]', 'Show task detail')
.action(
async (
taskId: string | undefined,
taskOpts: {
gateway: string;
list?: boolean;
new?: boolean;
update?: string;
mission?: string;
},
) => {
const auth = await withAuth(taskOpts.gateway);
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
if (!missionId) return;
if (taskOpts.list) {
return listTasks(auth.gateway, auth.cookie, missionId);
}
if (taskOpts.new) {
return createTaskWizard(auth.gateway, auth.cookie, missionId);
}
if (taskOpts.update) {
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
}
if (taskId) {
return showTask(auth.gateway, auth.cookie, missionId, taskId);
}
return listTasks(auth.gateway, auth.cookie, missionId);
},
);
return cmd;
}
async function resolveMissionByName(
gateway: string,
cookie: string,
idOrName: string,
): Promise<MissionInfo | undefined> {
const missions = await fetchMissions(gateway, cookie);
return missions.find((m) => m.id === idOrName || m.name === idOrName);
}
async function resolveMissionId(
gateway: string,
cookie: string,
idOrName?: string,
): Promise<string | undefined> {
if (idOrName) {
const mission = await resolveMissionByName(gateway, cookie, idOrName);
if (!mission) {
console.error(`Mission "${idOrName}" not found.`);
return undefined;
}
return mission.id;
}
// Interactive select
const missions = await fetchMissions(gateway, cookie);
const selected = await selectItem(missions, {
message: 'Select a mission:',
render: formatMission,
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
});
return selected?.id;
}
async function listMissions(gateway: string, cookie: string) {
const missions = await fetchMissions(gateway, cookie);
if (missions.length === 0) {
console.log('No missions found.');
return;
}
console.log(`Missions (${missions.length}):\n`);
for (const m of missions) {
const phase = m.phase ? ` [${m.phase}]` : '';
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
}
}
async function showMission(gateway: string, cookie: string, id: string) {
try {
const mission = await fetchMission(gateway, cookie, id);
showMissionDetail(mission);
} catch {
// Try resolving by name
const m = await resolveMissionByName(gateway, cookie, id);
if (!m) {
console.error(`Mission "${id}" not found.`);
process.exit(1);
}
showMissionDetail(m);
}
}
async function interactiveSelect(gateway: string, cookie: string) {
const missions = await fetchMissions(gateway, cookie);
const selected = await selectItem(missions, {
message: 'Select a mission:',
render: formatMission,
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
});
if (selected) {
showMissionDetail(selected);
}
}
async function initMission(gateway: string, cookie: string) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const name = await ask('Mission name: ');
if (!name.trim()) {
console.error('Name is required.');
return;
}
// Project selection
const projects = await fetchProjects(gateway, cookie);
let projectId: string | undefined;
if (projects.length > 0) {
const selected = await selectItem(projects, {
message: 'Assign to project (required):',
render: (p) => `${p.name} (${p.status})`,
emptyMessage: 'No projects found.',
});
if (selected) projectId = selected.id;
}
const description = await ask('Description (optional): ');
const mission = await createMission(gateway, cookie, {
name: name.trim(),
projectId,
description: description.trim() || undefined,
status: 'planning',
});
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
} finally {
rl.close();
}
}
async function planMission(
gateway: string,
cookie: string,
idOrName: string,
_projectIdOrName?: string,
) {
const mission = await resolveMissionByName(gateway, cookie, idOrName);
if (!mission) {
console.error(`Mission "${idOrName}" not found.`);
process.exit(1);
}
console.log(`Planning mission: ${mission.name}\n`);
try {
const { runPrdWizard } = await import('@mosaic/prdy');
await runPrdWizard({
name: mission.name,
projectPath: process.cwd(),
interactive: true,
});
} catch (err) {
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
const mission = await resolveMissionByName(gateway, cookie, idOrName);
if (!mission) {
console.error(`Mission "${idOrName}" not found.`);
process.exit(1);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
console.log(`Updating mission: ${mission.name}\n`);
const name = await ask(`Name [${mission.name}]: `);
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
const status = await ask(`Status [${mission.status}]: `);
const updates: Record<string, unknown> = {};
if (name.trim()) updates['name'] = name.trim();
if (description.trim()) updates['description'] = description.trim();
if (status.trim()) updates['status'] = status.trim();
if (Object.keys(updates).length === 0) {
console.log('No changes.');
return;
}
const updated = await updateMission(gateway, cookie, mission.id, updates);
console.log(`\nMission "${updated.name}" updated.`);
} finally {
rl.close();
}
}
// ── Task operations ──
async function listTasks(gateway: string, cookie: string, missionId: string) {
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
if (tasks.length === 0) {
console.log('No tasks found.');
return;
}
console.log(`Tasks (${tasks.length}):\n`);
for (const t of tasks) {
const desc = t.description ? `${t.description.slice(0, 60)}` : '';
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
}
}
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
const task = tasks.find((t) => t.id === taskId);
if (!task) {
console.error(`Task "${taskId}" not found.`);
process.exit(1);
}
showTaskDetail(task);
}
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const description = await ask('Task description: ');
if (!description.trim()) {
console.error('Description is required.');
return;
}
const status = await ask('Status [not-started]: ');
const task = await createMissionTask(gateway, cookie, missionId, {
description: description.trim(),
status: status.trim() || 'not-started',
});
console.log(`\nTask created (${task.id}).`);
} finally {
rl.close();
}
}
async function updateTaskWizard(
gateway: string,
cookie: string,
missionId: string,
taskId: string,
) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
try {
const status = await ask('New status: ');
const notes = await ask('Notes (optional): ');
const pr = await ask('PR (optional): ');
const updates: Record<string, unknown> = {};
if (status.trim()) updates['status'] = status.trim();
if (notes.trim()) updates['notes'] = notes.trim();
if (pr.trim()) updates['pr'] = pr.trim();
if (Object.keys(updates).length === 0) {
console.log('No changes.');
return;
}
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
} finally {
rl.close();
}
}

View File

@@ -0,0 +1,55 @@
import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { fetchProjects } from '../tui/gateway-api.js';
export function registerPrdyCommand(program: Command) {
const cmd = program
.command('prdy')
.description('PRD wizard — create and manage Product Requirement Documents')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--init [name]', 'Create a new PRD')
.option('--update [name]', 'Update an existing PRD')
.option('--project <idOrName>', 'Scope to project')
.action(
async (opts: {
gateway: string;
init?: string | boolean;
update?: string | boolean;
project?: string;
}) => {
// Detect project context when --project flag is provided
if (opts.project) {
try {
const auth = await withAuth(opts.gateway);
const projects = await fetchProjects(auth.gateway, auth.cookie);
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
if (match) {
console.log(`Project context: ${match.name} (${match.id})\n`);
}
} catch {
// Gateway not available — proceed without project context
}
}
try {
const { runPrdWizard } = await import('@mosaic/prdy');
const name =
typeof opts.init === 'string'
? opts.init
: typeof opts.update === 'string'
? opts.update
: 'untitled';
await runPrdWizard({
name,
projectPath: process.cwd(),
interactive: true,
});
} catch (err) {
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
},
);
return cmd;
}

View File

@@ -0,0 +1,58 @@
/**
* Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list.
*/
export async function selectItem<T>(
items: T[],
opts: {
message: string;
render: (item: T) => string;
emptyMessage?: string;
},
): Promise<T | undefined> {
if (items.length === 0) {
console.log(opts.emptyMessage ?? 'No items found.');
return undefined;
}
const isTTY = process.stdin.isTTY;
if (isTTY) {
try {
const { select } = await import('@clack/prompts');
const result = await select({
message: opts.message,
options: items.map((item, i) => ({
value: i,
label: opts.render(item),
})),
});
if (typeof result === 'symbol') {
return undefined;
}
return items[result as number];
} catch {
// Fall through to non-interactive
}
}
// Non-interactive: display numbered list and read a number
console.log(`\n${opts.message}\n`);
for (let i = 0; i < items.length; i++) {
console.log(` ${i + 1}. ${opts.render(items[i]!)}`);
}
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) => rl.question('\nSelect: ', resolve));
rl.close();
const index = parseInt(answer, 10) - 1;
if (isNaN(index) || index < 0 || index >= items.length) {
console.error('Invalid selection.');
return undefined;
}
return items[index];
}

View File

@@ -0,0 +1,29 @@
import type { AuthResult } from '../auth.js';
export interface AuthContext {
gateway: string;
session: AuthResult;
cookie: string;
}
/**
* Load and validate the user's auth session.
* Exits with an error message if not signed in or session expired.
*/
export async function withAuth(gateway: string): Promise<AuthContext> {
const { loadSession, validateSession } = await import('../auth.js');
const session = loadSession(gateway);
if (!session) {
console.error('Not signed in. Run `mosaic login` first.');
process.exit(1);
}
const valid = await validateSession(gateway, session.cookie);
if (!valid) {
console.error('Session expired. Run `mosaic login` again.');
process.exit(1);
}
return { gateway, session, cookie: session.cookie };
}

View File

@@ -1,101 +1,11 @@
#!/usr/bin/env node export const VERSION = '0.0.0';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
import { ClackPrompter } from './prompter/clack-prompter.js';
import { HeadlessPrompter } from './prompter/headless-prompter.js';
import { createConfigService } from './config/config-service.js';
import { runWizard } from './wizard.js';
import { WizardCancelledError } from './errors.js';
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
import type { CommunicationStyle } from './types.js';
export { VERSION, DEFAULT_MOSAIC_HOME };
export { runWizard } from './wizard.js';
export { export {
checkForUpdate,
backgroundUpdateCheck, backgroundUpdateCheck,
checkForUpdate,
formatUpdateNotice, formatUpdateNotice,
getInstallCommand,
getInstalledVersion, getInstalledVersion,
getLatestVersion, getLatestVersion,
semverLt, semverLt,
} from './runtime/update-checker.js'; } from './runtime/update-checker.js';
export type { UpdateCheckResult } from './runtime/update-checker.js';
export { ClackPrompter } from './prompter/clack-prompter.js';
export { HeadlessPrompter } from './prompter/headless-prompter.js';
export { createConfigService } from './config/config-service.js';
export { WizardCancelledError } from './errors.js';
const program = new Command()
.name('mosaic-wizard')
.description('Mosaic Installation Wizard')
.version(VERSION);
program
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
.option('--source-dir <path>', 'Source directory for framework files')
.option('--mosaic-home <path>', 'Target config directory', DEFAULT_MOSAIC_HOME)
// SOUL.md overrides
.option('--name <name>', 'Agent name')
.option('--role <description>', 'Agent role description')
.option('--style <style>', 'Communication style: direct|friendly|formal')
.option('--accessibility <prefs>', 'Accessibility preferences')
.option('--guardrails <rules>', 'Custom guardrails')
// USER.md overrides
.option('--user-name <name>', 'Your name')
.option('--pronouns <pronouns>', 'Your pronouns')
.option('--timezone <tz>', 'Your timezone')
.action(async (opts: Record<string, string | boolean | undefined>) => {
try {
const mosaicHome = (opts['mosaicHome'] as string) ?? DEFAULT_MOSAIC_HOME;
// Default source to the framework/ dir bundled in this npm package.
// This ensures syncFramework copies AGENTS.md, STANDARDS.md, guides/, etc.
// Falls back to mosaicHome if the bundled dir doesn't exist (shouldn't happen).
const pkgRoot = dirname(fileURLToPath(import.meta.url));
const bundledFramework = resolve(pkgRoot, '..', 'framework');
const sourceDir =
(opts['sourceDir'] as string | undefined) ??
(existsSync(bundledFramework) ? bundledFramework : mosaicHome);
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
const configService = createConfigService(mosaicHome, sourceDir);
const style = opts['style'] as CommunicationStyle | undefined;
await runWizard({
mosaicHome,
sourceDir,
prompter,
configService,
cliOverrides: {
soul: {
agentName: opts['name'] as string | undefined,
roleDescription: opts['role'] as string | undefined,
communicationStyle: style,
accessibility: opts['accessibility'] as string | undefined,
customGuardrails: opts['guardrails'] as string | undefined,
},
user: {
userName: opts['userName'] as string | undefined,
pronouns: opts['pronouns'] as string | undefined,
timezone: opts['timezone'] as string | undefined,
},
},
});
} catch (err) {
if (err instanceof WizardCancelledError) {
console.log('\nWizard cancelled.');
process.exit(0);
}
console.error('Wizard failed:', err);
process.exit(1);
}
});
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
program.parse();
}

View File

@@ -1,6 +1,6 @@
/** /**
* Mosaic update checker — compares installed @mosaic/cli version against the * Mosaic update checker — compares the installed @mosaic/mosaic package
* Gitea npm registry and reports when an upgrade is available. * against the Gitea npm registry and reports when an upgrade is available.
* *
* Used by: * Used by:
* - CLI startup (non-blocking background check) * - CLI startup (non-blocking background check)
@@ -23,8 +23,12 @@ import { join } from 'node:path';
export interface UpdateCheckResult { export interface UpdateCheckResult {
/** Currently installed version (empty if not found) */ /** Currently installed version (empty if not found) */
current: string; current: string;
/** Currently installed package name */
currentPackage?: string;
/** Latest published version (empty if check failed) */ /** Latest published version (empty if check failed) */
latest: string; latest: string;
/** Package that should be installed for the latest version */
targetPackage?: string;
/** True when a newer version is available */ /** True when a newer version is available */
updateAvailable: boolean; updateAvailable: boolean;
/** ISO timestamp of this check */ /** ISO timestamp of this check */
@@ -36,7 +40,7 @@ export interface UpdateCheckResult {
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/'; const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
const CLI_PKG = '@mosaic/cli'; const PKG = '@mosaic/mosaic';
const CACHE_DIR = join(homedir(), '.cache', 'mosaic'); const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
const CACHE_FILE = join(CACHE_DIR, 'update-check.json'); const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -154,31 +158,40 @@ function writeCache(entry: RegistryCache): void {
// ─── Public API ───────────────────────────────────────────────────────────── // ─── Public API ─────────────────────────────────────────────────────────────
/** /**
* Get the currently installed version of @mosaic/cli. * Get the currently installed @mosaic/mosaic version.
* Returns empty string if not installed.
*/ */
export function getInstalledVersion(): string { export function getInstalledVersion(): { name: string; version: string } {
// Fast path: check via package.json require chain
try { try {
const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000); const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000);
if (raw) { if (raw) {
const data = JSON.parse(raw) as { const data = JSON.parse(raw) as {
dependencies?: Record<string, { version?: string }>; dependencies?: Record<string, { version?: string }>;
}; };
return data?.dependencies?.[CLI_PKG]?.version ?? ''; const version = data?.dependencies?.[PKG]?.version;
if (version) {
return { name: PKG, version };
}
} }
} catch { } catch {
// fall through // fall through
} }
return ''; return { name: '', version: '' };
} }
/** /**
* Fetch the latest published version from the Gitea npm registry. * Fetch the latest published @mosaic/mosaic version from the Gitea npm registry.
* Returns empty string on failure. * Returns empty string on failure.
*/ */
export function getLatestVersion(): string { export function getLatestVersion(): { name: string; version: string } {
return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`); const version = npmExec(`view ${PKG} version --registry=${REGISTRY}`);
if (version) {
return { name: PKG, version };
}
return { name: '', version: '' };
}
export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage'>): string {
return `npm i -g ${result.targetPackage || PKG}@latest`;
} }
/** /**
@@ -188,31 +201,45 @@ export function getLatestVersion(): string {
* Never throws. * Never throws.
*/ */
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult { export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
const current = getInstalledVersion(); const currentInfo = getInstalledVersion();
const current = currentInfo.version;
let latest: string; let latestInfo: { name: string; version: string };
let checkedAt: string; let checkedAt: string;
if (!options?.skipCache) { if (!options?.skipCache) {
const cached = readCache(); const cached = readCache();
if (cached) { if (cached) {
latest = cached.latest; latestInfo = {
name: PKG,
version: cached.latest,
};
checkedAt = cached.checkedAt; checkedAt = cached.checkedAt;
} else { } else {
latest = getLatestVersion(); latestInfo = getLatestVersion();
checkedAt = new Date().toISOString(); checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY }); writeCache({
latest: latestInfo.version,
checkedAt,
registry: REGISTRY,
});
} }
} else { } else {
latest = getLatestVersion(); latestInfo = getLatestVersion();
checkedAt = new Date().toISOString(); checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY }); writeCache({
latest: latestInfo.version,
checkedAt,
registry: REGISTRY,
});
} }
return { return {
current, current,
latest, currentPackage: currentInfo.name || PKG,
updateAvailable: !!(current && latest && semverLt(current, latest)), latest: latestInfo.version,
targetPackage: latestInfo.name || PKG,
updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)),
checkedAt, checkedAt,
registry: REGISTRY, registry: REGISTRY,
}; };
@@ -224,6 +251,8 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe
export function formatUpdateNotice(result: UpdateCheckResult): string { export function formatUpdateNotice(result: UpdateCheckResult): string {
if (!result.updateAvailable) return ''; if (!result.updateAvailable) return '';
const installCommand = getInstallCommand(result);
const lines = [ const lines = [
'', '',
'╭─────────────────────────────────────────────────╮', '╭─────────────────────────────────────────────────╮',
@@ -231,7 +260,7 @@ export function formatUpdateNotice(result: UpdateCheckResult): string {
`│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│', `│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│',
'│ │', '│ │',
'│ Run: bash tools/install.sh │', '│ Run: bash tools/install.sh │',
'│ Or: npm i -g @mosaic/cli@latest │', `│ Or: ${installCommand}`.padEnd(50) + '│',
'│ │', '│ │',
'╰─────────────────────────────────────────────────╯', '╰─────────────────────────────────────────────────╯',
'', '',

View File

@@ -0,0 +1,468 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Box, useApp, useInput } from 'ink';
import type { ParsedCommand } from '@mosaic/types';
import { TopBar } from './components/top-bar.js';
import { BottomBar } from './components/bottom-bar.js';
import { MessageList } from './components/message-list.js';
import { InputBar } from './components/input-bar.js';
import { Sidebar } from './components/sidebar.js';
import { SearchBar } from './components/search-bar.js';
import { useSocket } from './hooks/use-socket.js';
import { useGitInfo } from './hooks/use-git-info.js';
import { useViewport } from './hooks/use-viewport.js';
import { useAppMode } from './hooks/use-app-mode.js';
import { useConversations } from './hooks/use-conversations.js';
import { useSearch } from './hooks/use-search.js';
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
import { fetchConversationMessages } from './gateway-api.js';
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
export interface TuiAppProps {
gatewayUrl: string;
conversationId?: string;
sessionCookie?: string;
initialModel?: string;
initialProvider?: string;
agentId?: string;
agentName?: string;
projectId?: string;
/** CLI package version passed from the entry point (cli.ts). */
version?: string;
}
export function TuiApp({
gatewayUrl,
conversationId,
sessionCookie,
initialModel,
initialProvider,
agentId,
agentName,
projectId: _projectId,
version = '0.0.0',
}: TuiAppProps) {
const { exit } = useApp();
const gitInfo = useGitInfo();
const appMode = useAppMode();
const socket = useSocket({
gatewayUrl,
sessionCookie,
initialConversationId: conversationId,
initialModel,
initialProvider,
agentId,
});
const conversations = useConversations({ gatewayUrl, sessionCookie });
const viewport = useViewport({ totalItems: socket.messages.length });
const search = useSearch(socket.messages);
// Scroll to current match when it changes
const currentMatch = search.matches[search.currentMatchIndex];
useEffect(() => {
if (currentMatch && appMode.mode === 'search') {
viewport.scrollTo(currentMatch.messageIndex);
}
}, [currentMatch, appMode.mode, viewport]);
// Compute highlighted message indices for MessageList
const highlightedMessageIndices = useMemo(() => {
if (search.matches.length === 0) return undefined;
return new Set(search.matches.map((m) => m.messageIndex));
}, [search.matches]);
const currentHighlightIndex = currentMatch?.messageIndex;
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
// Controlled input state — held here so Ctrl+C can clear it
const [tuiInput, setTuiInput] = useState('');
// Ctrl+C double-press: first press with empty input shows hint; second exits
const ctrlCPendingExit = useRef(false);
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
const ctrlJustFired = useRef(false);
// Wrap sendMessage to expand @file references before sending
const sendMessageWithFileRefs = useCallback(
(content: string) => {
if (!hasFileRefs(content)) {
socket.sendMessage(content);
return;
}
void expandFileRefs(content)
.then(({ expandedMessage, filesAttached, errors }) => {
for (const err of errors) {
socket.addSystemMessage(err);
}
if (filesAttached.length > 0) {
socket.addSystemMessage(
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
);
}
socket.sendMessage(expandedMessage);
})
.catch((err: unknown) => {
socket.addSystemMessage(
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
);
// Send original message without expansion
socket.sendMessage(content);
});
},
[socket],
);
const handleLocalCommand = useCallback(
(parsed: ParsedCommand) => {
switch (parsed.command) {
case 'help':
case 'h': {
const result = executeHelp(parsed);
socket.addSystemMessage(result);
break;
}
case 'status':
case 's': {
const result = executeStatus(parsed, {
connected: socket.connected,
model: socket.modelName,
provider: socket.providerName,
sessionId: socket.conversationId ?? null,
tokenCount: socket.tokenUsage.total,
});
socket.addSystemMessage(result);
break;
}
case 'clear':
socket.clearMessages();
break;
case 'new':
case 'n':
void conversations
.createConversation()
.then((conv) => {
if (conv) {
socket.switchConversation(conv.id);
appMode.setMode('chat');
}
})
.catch(() => {
socket.addSystemMessage('Failed to create new conversation.');
});
break;
case 'attach': {
if (!parsed.args) {
socket.addSystemMessage('Usage: /attach <file-path>');
break;
}
void handleAttachCommand(parsed.args)
.then(({ content, error }) => {
if (error) {
socket.addSystemMessage(`Attach error: ${error}`);
} else if (content) {
// Send the file content as a user message
socket.sendMessage(content);
}
})
.catch((err: unknown) => {
socket.addSystemMessage(
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
break;
}
case 'stop':
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
socket.socketRef.current.emit('abort', {
conversationId: socket.conversationId,
});
socket.addSystemMessage('Abort signal sent.');
} else {
socket.addSystemMessage('No active stream to stop.');
}
break;
case 'cost': {
const u = socket.tokenUsage;
socket.addSystemMessage(
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
);
break;
}
case 'history':
case 'hist': {
void executeHistory({
conversationId: socket.conversationId,
gatewayUrl,
sessionCookie,
fetchMessages: fetchConversationMessages,
})
.then((result) => {
socket.addSystemMessage(result);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
socket.addSystemMessage(`Failed to fetch history: ${msg}`);
});
break;
}
default:
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
}
},
[socket],
);
const handleGatewayCommand = useCallback(
(parsed: ParsedCommand) => {
if (!socket.socketRef.current?.connected) {
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
return;
}
socket.socketRef.current.emit('command:execute', {
conversationId: socket.conversationId ?? '',
command: parsed.command,
args: parsed.args ?? undefined,
});
},
[socket],
);
const handleSwitchConversation = useCallback(
(id: string) => {
socket.switchConversation(id);
appMode.setMode('chat');
},
[socket, appMode],
);
const handleDeleteConversation = useCallback(
(id: string) => {
void conversations
.deleteConversation(id)
.then((ok) => {
if (ok && id === socket.conversationId) {
socket.clearMessages();
}
})
.catch(() => {});
},
[conversations, socket],
);
useInput((ch, key) => {
// Ctrl+C: clear input → show hint → second empty press exits
if (key.ctrl && ch === 'c') {
if (tuiInput) {
setTuiInput('');
ctrlCPendingExit.current = false;
} else if (ctrlCPendingExit.current) {
exit();
} else {
ctrlCPendingExit.current = true;
socket.addSystemMessage('Press Ctrl+C again to exit.');
}
return;
}
// Any other key resets the pending-exit flag
ctrlCPendingExit.current = false;
// Ctrl+L: toggle sidebar (refresh on open)
if (key.ctrl && ch === 'l') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
const willOpen = !appMode.sidebarOpen;
appMode.toggleSidebar();
if (willOpen) {
void conversations.refresh();
}
return;
}
// Ctrl+N: create new conversation and switch to it
if (key.ctrl && ch === 'n') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
void conversations
.createConversation()
.then((conv) => {
if (conv) {
socket.switchConversation(conv.id);
appMode.setMode('chat');
}
})
.catch(() => {});
return;
}
// Ctrl+K: toggle search mode
if (key.ctrl && ch === 'k') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
if (appMode.mode === 'search') {
search.clear();
appMode.setMode('chat');
} else {
appMode.setMode('search');
}
return;
}
// Page Up / Page Down: scroll message history (only in chat mode)
if (appMode.mode === 'chat') {
if (key.pageUp) {
viewport.scrollBy(-viewport.viewportSize);
}
if (key.pageDown) {
viewport.scrollBy(viewport.viewportSize);
}
}
// Ctrl+T: cycle thinking level
if (key.ctrl && ch === 't') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
const levels = socket.availableThinkingLevels;
if (levels.length > 0) {
const currentIdx = levels.indexOf(socket.thinkingLevel);
const nextIdx = (currentIdx + 1) % levels.length;
const next = levels[nextIdx];
if (next) {
socket.setThinkingLevel(next);
}
}
return;
}
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
if (key.escape) {
if (appMode.mode === 'search') {
search.clear();
appMode.setMode('chat');
} else if (appMode.mode === 'sidebar') {
appMode.setMode('chat');
} else if (appMode.mode === 'chat') {
viewport.scrollToBottom();
}
}
});
const inputPlaceholder =
appMode.mode === 'sidebar'
? 'focus is on sidebar… press Esc to return'
: appMode.mode === 'search'
? 'search mode… press Esc to return'
: undefined;
const isSearchMode = appMode.mode === 'search';
const messageArea = (
<Box flexDirection="column" flexGrow={1}>
<MessageList
messages={socket.messages}
isStreaming={socket.isStreaming}
currentStreamText={socket.currentStreamText}
currentThinkingText={socket.currentThinkingText}
activeToolCalls={socket.activeToolCalls}
scrollOffset={viewport.scrollOffset}
viewportSize={viewport.viewportSize}
isScrolledUp={viewport.isScrolledUp}
highlightedMessageIndices={highlightedMessageIndices}
currentHighlightIndex={currentHighlightIndex}
/>
{isSearchMode && (
<SearchBar
query={search.query}
onQueryChange={search.setQuery}
totalMatches={search.totalMatches}
currentMatch={search.currentMatchIndex}
onNext={search.nextMatch}
onPrev={search.prevMatch}
onClose={() => {
search.clear();
appMode.setMode('chat');
}}
focused={isSearchMode}
/>
)}
<InputBar
value={tuiInput}
onChange={(val: string) => {
// Suppress the character that ink-text-input leaks when a Ctrl+key
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
// set synchronously in the useInput handler and cleared via a
// microtask, so this callback sees it as still true on the same
// event-loop tick.
if (ctrlJustFired.current) {
ctrlJustFired.current = false;
return;
}
setTuiInput(val);
}}
onSubmit={sendMessageWithFileRefs}
onSystemMessage={socket.addSystemMessage}
onLocalCommand={handleLocalCommand}
onGatewayCommand={handleGatewayCommand}
isStreaming={socket.isStreaming}
connected={socket.connected}
focused={appMode.mode === 'chat'}
placeholder={inputPlaceholder}
allCommands={commandRegistry.getAll()}
/>
</Box>
);
return (
<Box flexDirection="column" height="100%">
<Box marginTop={1} />
<TopBar
gatewayUrl={gatewayUrl}
version={version}
modelName={socket.modelName}
thinkingLevel={socket.thinkingLevel}
contextWindow={socket.tokenUsage.contextWindow}
agentName={agentName ?? 'default'}
connected={socket.connected}
connecting={socket.connecting}
/>
{appMode.sidebarOpen ? (
<Box flexDirection="row" flexGrow={1}>
<Sidebar
conversations={conversations.conversations}
activeConversationId={socket.conversationId}
selectedIndex={sidebarSelectedIndex}
onSelectIndex={setSidebarSelectedIndex}
onSwitchConversation={handleSwitchConversation}
onDeleteConversation={handleDeleteConversation}
loading={conversations.loading}
focused={appMode.mode === 'sidebar'}
width={30}
/>
{messageArea}
</Box>
) : (
<Box flexGrow={1}>{messageArea}</Box>
)}
<BottomBar
gitInfo={gitInfo}
tokenUsage={socket.tokenUsage}
connected={socket.connected}
connecting={socket.connecting}
modelName={socket.modelName}
providerName={socket.providerName}
thinkingLevel={socket.thinkingLevel}
conversationId={socket.conversationId}
routingDecision={socket.routingDecision}
/>
</Box>
);
}

View File

@@ -0,0 +1,348 @@
/**
* Integration tests for TUI command parsing + registry (P8-019)
*
* Covers:
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
* - /help, /stop, /cost, /status resolve to 'local' execution
* - Unknown commands return null from find()
* - Alias resolution: /h → help, /m → model, /n → new, etc.
* - filterCommands prefix filtering
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { parseSlashCommand } from './parse.js';
import { CommandRegistry } from './registry.js';
import type { CommandDef } from '@mosaic/types';
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
describe('parseSlashCommand + CommandRegistry — integration', () => {
let registry: CommandRegistry;
// Gateway-style commands to simulate a live manifest
const gatewayCommands: CommandDef[] = [
{
name: 'model',
description: 'Switch the active model',
aliases: ['m'],
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'thinking',
description: 'Set thinking level',
aliases: ['t'],
args: [
{
name: 'level',
type: 'enum',
optional: false,
values: ['none', 'low', 'medium', 'high', 'auto'],
description: 'Thinking level',
},
],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'new',
description: 'Start a new conversation',
aliases: ['n'],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'agent',
description: 'Switch or list available agents',
aliases: ['a'],
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
scope: 'agent',
execution: 'socket',
available: true,
},
{
name: 'preferences',
description: 'View or set user preferences',
aliases: ['pref'],
args: [
{
name: 'action',
type: 'enum',
optional: true,
values: ['show', 'set', 'reset'],
description: 'Action',
},
],
scope: 'core',
execution: 'rest',
available: true,
},
{
name: 'gc',
description: 'Trigger garbage collection sweep',
aliases: [],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'mission',
description: 'View or set active mission',
aliases: [],
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
scope: 'agent',
execution: 'socket',
available: true,
},
];
beforeEach(() => {
registry = new CommandRegistry();
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
});
// ── parseSlashCommand tests ──
it('returns null for non-slash input', () => {
expect(parseSlashCommand('hello world')).toBeNull();
expect(parseSlashCommand('')).toBeNull();
expect(parseSlashCommand('model')).toBeNull();
});
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
const parsed = parseSlashCommand('/model claude-3-opus');
expect(parsed).not.toBeNull();
expect(parsed!.command).toBe('model');
expect(parsed!.args).toBe('claude-3-opus');
expect(parsed!.raw).toBe('/model claude-3-opus');
});
it('parses "/gc" with no args → command=gc args=null', () => {
const parsed = parseSlashCommand('/gc');
expect(parsed).not.toBeNull();
expect(parsed!.command).toBe('gc');
expect(parsed!.args).toBeNull();
});
it('parses "/system you are a helpful assistant" → args contains full text', () => {
const parsed = parseSlashCommand('/system you are a helpful assistant');
expect(parsed!.command).toBe('system');
expect(parsed!.args).toBe('you are a helpful assistant');
});
it('parses "/help" → command=help args=null', () => {
const parsed = parseSlashCommand('/help');
expect(parsed!.command).toBe('help');
expect(parsed!.args).toBeNull();
});
// ── Round-trip: parse then find ──
it('round-trip: /m → resolves to "model" command via alias', () => {
const parsed = parseSlashCommand('/m claude-3-haiku');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
// /m → model (alias map in registry)
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
});
it('round-trip: /h → resolves to "help" (local command)', () => {
const parsed = parseSlashCommand('/h');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
});
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
const parsed = parseSlashCommand('/n');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
});
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
const parsed = parseSlashCommand('/a list');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
});
it('round-trip: /pref → resolves to "preferences" via alias', () => {
const parsed = parseSlashCommand('/pref show');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
});
it('round-trip: /t → resolves to "thinking" via alias', () => {
const parsed = parseSlashCommand('/t high');
expect(parsed).not.toBeNull();
const cmd = registry.find(parsed!.command);
expect(cmd).not.toBeNull();
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
});
// ── Local commands resolve to 'local' execution ──
it('/help resolves to local execution', () => {
const cmd = registry.find('help');
expect(cmd).not.toBeNull();
expect(cmd!.execution).toBe('local');
});
it('/stop resolves to local execution', () => {
const cmd = registry.find('stop');
expect(cmd).not.toBeNull();
expect(cmd!.execution).toBe('local');
});
it('/cost resolves to local execution', () => {
const cmd = registry.find('cost');
expect(cmd).not.toBeNull();
expect(cmd!.execution).toBe('local');
});
it('/status resolves to local execution (TUI local override)', () => {
const cmd = registry.find('status');
expect(cmd).not.toBeNull();
// status is 'local' in the TUI registry (local takes precedence over gateway)
expect(cmd!.execution).toBe('local');
});
// ── Unknown commands return null ──
it('find() returns null for unknown command', () => {
expect(registry.find('nonexistent')).toBeNull();
expect(registry.find('xyz')).toBeNull();
expect(registry.find('')).toBeNull();
});
it('find() returns null when no gateway manifest and command not local', () => {
const emptyRegistry = new CommandRegistry();
expect(emptyRegistry.find('model')).toBeNull();
expect(emptyRegistry.find('gc')).toBeNull();
});
// ── getAll returns combined local + gateway ──
it('getAll() includes both local and gateway commands', () => {
const all = registry.getAll();
const names = all.map((c) => c.name);
// Local commands
expect(names).toContain('help');
expect(names).toContain('stop');
expect(names).toContain('cost');
expect(names).toContain('status');
// Gateway commands
expect(names).toContain('model');
expect(names).toContain('gc');
});
it('getLocalCommands() returns only local commands', () => {
const local = registry.getLocalCommands();
expect(local.every((c) => c.execution === 'local')).toBe(true);
expect(local.some((c) => c.name === 'help')).toBe(true);
expect(local.some((c) => c.name === 'stop')).toBe(true);
});
});
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
describe('filterCommands (from CommandAutocomplete)', () => {
// Import inline since filterCommands is not exported — replicate the logic here
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
if (!query) return commands;
const q = query.toLowerCase();
return commands.filter(
(c) =>
c.name.includes(q) ||
c.aliases.some((a) => a.includes(q)) ||
c.description.toLowerCase().includes(q),
);
}
const commands: CommandDef[] = [
{
name: 'model',
description: 'Switch the active model',
aliases: ['m'],
scope: 'core',
execution: 'socket',
available: true,
},
{
name: 'mission',
description: 'View or set active mission',
aliases: [],
scope: 'agent',
execution: 'socket',
available: true,
},
{
name: 'help',
description: 'Show available commands',
aliases: ['h'],
scope: 'core',
execution: 'local',
available: true,
},
{
name: 'gc',
description: 'Trigger garbage collection sweep',
aliases: [],
scope: 'core',
execution: 'socket',
available: true,
},
];
it('returns all commands when query is empty', () => {
expect(filterCommands(commands, '')).toHaveLength(commands.length);
});
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
const result = filterCommands(commands, 'mi');
const names = result.map((c) => c.name);
expect(names).toContain('mission');
expect(names).not.toContain('gc');
});
it('filters by name prefix "mo" → model only', () => {
const result = filterCommands(commands, 'mo');
const names = result.map((c) => c.name);
expect(names).toContain('model');
expect(names).not.toContain('mission');
expect(names).not.toContain('gc');
});
it('filters by exact name "gc" → gc only', () => {
const result = filterCommands(commands, 'gc');
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('gc');
});
it('filters by alias "h" → help', () => {
const result = filterCommands(commands, 'h');
const names = result.map((c) => c.name);
expect(names).toContain('help');
});
it('filters by description keyword "switch" → model', () => {
const result = filterCommands(commands, 'switch');
const names = result.map((c) => c.name);
expect(names).toContain('model');
});
it('returns empty array when no commands match', () => {
const result = filterCommands(commands, 'zzznotfound');
expect(result).toHaveLength(0);
});
});

View File

@@ -0,0 +1,7 @@
export { parseSlashCommand } from './parse.js';
export { commandRegistry, CommandRegistry } from './registry.js';
export { executeHelp } from './local/help.js';
export { executeStatus } from './local/status.js';
export type { StatusContext } from './local/status.js';
export { executeHistory } from './local/history.js';
export type { HistoryContext } from './local/history.js';

View File

@@ -0,0 +1,19 @@
import type { ParsedCommand } from '@mosaic/types';
import { commandRegistry } from '../registry.js';
export function executeHelp(_parsed: ParsedCommand): string {
const commands = commandRegistry.getAll();
const lines = ['Available commands:', ''];
for (const cmd of commands) {
const aliases =
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
const argsStr =
cmd.args && cmd.args.length > 0
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
: '';
lines.push(` /${cmd.name}${argsStr}${aliases}${cmd.description}`);
}
return lines.join('\n').trimEnd();
}

View File

@@ -0,0 +1,53 @@
import type { ConversationMessage } from '../../gateway-api.js';
const CONTEXT_WINDOW = 200_000;
const CHARS_PER_TOKEN = 4;
function estimateTokens(messages: ConversationMessage[]): number {
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
return Math.round(totalChars / CHARS_PER_TOKEN);
}
export interface HistoryContext {
conversationId: string | undefined;
conversationTitle?: string | null;
gatewayUrl: string;
sessionCookie: string | undefined;
fetchMessages: (
gatewayUrl: string,
sessionCookie: string,
conversationId: string,
) => Promise<ConversationMessage[]>;
}
export async function executeHistory(ctx: HistoryContext): Promise<string> {
const { conversationId, conversationTitle, gatewayUrl, sessionCookie, fetchMessages } = ctx;
if (!conversationId) {
return 'No active conversation.';
}
if (!sessionCookie) {
return 'Not authenticated — cannot fetch conversation messages.';
}
const messages = await fetchMessages(gatewayUrl, sessionCookie, conversationId);
const userMessages = messages.filter((m) => m.role === 'user').length;
const assistantMessages = messages.filter((m) => m.role === 'assistant').length;
const totalMessages = messages.length;
const estimatedTokens = estimateTokens(messages);
const contextPercent = Math.round((estimatedTokens / CONTEXT_WINDOW) * 100);
const label = conversationTitle ?? conversationId;
const lines = [
`Conversation: ${label}`,
`Messages: ${totalMessages} (${userMessages} user, ${assistantMessages} assistant)`,
`Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
`Context usage: ~${contextPercent}% of ${(CONTEXT_WINDOW / 1000).toFixed(0)}K`,
];
return lines.join('\n');
}

View File

@@ -0,0 +1,20 @@
import type { ParsedCommand } from '@mosaic/types';
export interface StatusContext {
connected: boolean;
model: string | null;
provider: string | null;
sessionId: string | null;
tokenCount: number;
}
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
const lines = [
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
`Model: ${ctx.model ?? 'unknown'}`,
`Provider: ${ctx.provider ?? 'unknown'}`,
`Session: ${ctx.sessionId ?? 'none'}`,
`Tokens (session): ${ctx.tokenCount}`,
];
return lines.join('\n');
}

View File

@@ -0,0 +1,11 @@
import type { ParsedCommand } from '@mosaic/types';
export function parseSlashCommand(input: string): ParsedCommand | null {
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
if (!match) return null;
return {
command: match[1]!,
args: match[2]?.trim() || null,
raw: input,
};
}

View File

@@ -0,0 +1,137 @@
import type { CommandDef, CommandManifest } from '@mosaic/types';
// Local-only commands (work even when gateway is disconnected)
const LOCAL_COMMANDS: CommandDef[] = [
{
name: 'help',
description: 'Show available commands',
aliases: ['h'],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'stop',
description: 'Cancel current streaming response',
aliases: [],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'cost',
description: 'Show token usage and cost for current session',
aliases: [],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'status',
description: 'Show connection and session status',
aliases: ['s'],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'history',
description: 'Show conversation message count and context usage',
aliases: ['hist'],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'clear',
description: 'Clear the current conversation display',
aliases: [],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'attach',
description: 'Attach a file to the next message (@file syntax also works inline)',
aliases: [],
args: [
{
name: 'path',
type: 'string' as const,
optional: false,
description: 'File path to attach',
},
],
execution: 'local',
available: true,
scope: 'core',
},
{
name: 'new',
description: 'Start a new conversation',
aliases: ['n'],
args: undefined,
execution: 'local',
available: true,
scope: 'core',
},
];
const ALIASES: Record<string, string> = {
m: 'model',
t: 'thinking',
a: 'agent',
s: 'status',
h: 'help',
hist: 'history',
pref: 'preferences',
};
export class CommandRegistry {
private gatewayManifest: CommandManifest | null = null;
updateManifest(manifest: CommandManifest): void {
this.gatewayManifest = manifest;
}
resolveAlias(command: string): string {
return ALIASES[command] ?? command;
}
find(command: string): CommandDef | null {
const resolved = this.resolveAlias(command);
// Search local first, then gateway manifest
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
if (local) return local;
if (this.gatewayManifest) {
return (
this.gatewayManifest.commands.find(
(c) => c.name === resolved || c.aliases.includes(resolved),
) ?? null
);
}
return null;
}
getAll(): CommandDef[] {
const gateway = this.gatewayManifest?.commands ?? [];
// Local commands take precedence; deduplicate gateway commands that share
// a name with a local command to avoid duplicate React keys and confusing
// autocomplete entries.
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
return [...LOCAL_COMMANDS, ...dedupedGateway];
}
getLocalCommands(): CommandDef[] {
return LOCAL_COMMANDS;
}
}
export const commandRegistry = new CommandRegistry();

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { RoutingDecisionInfo } from '@mosaic/types';
import type { TokenUsage } from '../hooks/use-socket.js';
import type { GitInfo } from '../hooks/use-git-info.js';
export interface BottomBarProps {
gitInfo: GitInfo;
tokenUsage: TokenUsage;
connected: boolean;
connecting: boolean;
modelName: string | null;
providerName: string | null;
thinkingLevel: string;
conversationId: string | undefined;
/** Routing decision info for transparency display (M4-008) */
routingDecision?: RoutingDecisionInfo | null;
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return String(n);
}
/** Compact the cwd — replace home with ~ */
function compactCwd(cwd: string): string {
const home = process.env['HOME'] ?? '';
if (home && cwd.startsWith(home)) {
return '~' + cwd.slice(home.length);
}
return cwd;
}
export function BottomBar({
gitInfo,
tokenUsage,
connected,
connecting,
modelName,
providerName,
thinkingLevel,
conversationId,
routingDecision,
}: BottomBarProps) {
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
const hasTokens = tokenUsage.total > 0;
return (
<Box flexDirection="column" paddingX={0} marginTop={0}>
{/* Line 0: keybinding hints */}
<Box>
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
</Box>
{/* Line 1: blank ····· Gateway: Status */}
<Box justifyContent="space-between">
<Box />
<Box>
<Text dimColor>Gateway: </Text>
<Text color={gatewayColor}>{gatewayStatus}</Text>
</Box>
</Box>
{/* Line 2: cwd (branch) ····· Session: id */}
<Box justifyContent="space-between">
<Box>
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
</Box>
<Box>
<Text dimColor>
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
</Text>
</Box>
</Box>
{/* Line 3: token stats ····· (provider) model */}
<Box justifyContent="space-between" minHeight={1}>
<Box>
{hasTokens ? (
<>
<Text dimColor>{formatTokens(tokenUsage.input)}</Text>
<Text dimColor>{' '}</Text>
<Text dimColor>{formatTokens(tokenUsage.output)}</Text>
{tokenUsage.cacheRead > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
</>
)}
{tokenUsage.cacheWrite > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
</>
)}
{tokenUsage.cost > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
</>
)}
{tokenUsage.contextPercent > 0 && (
<>
<Text dimColor>{' '}</Text>
<Text dimColor>
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
</Text>
</>
)}
</>
) : (
<Text dimColor>0 0 $0.000</Text>
)}
</Box>
<Box>
<Text dimColor>
{providerName ? `(${providerName}) ` : ''}
{modelName ?? 'awaiting model'}
{thinkingLevel !== 'off' ? `${thinkingLevel}` : ''}
</Text>
</Box>
</Box>
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
{routingDecision && (
<Box>
<Text dimColor>
Routed: {routingDecision.model} ({routingDecision.reason})
</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { CommandDef, CommandArgDef } from '@mosaic/types';
interface CommandAutocompleteProps {
commands: CommandDef[];
selectedIndex: number;
inputValue: string; // the current input after '/'
}
export function CommandAutocomplete({
commands,
selectedIndex,
inputValue,
}: CommandAutocompleteProps) {
if (commands.length === 0) return null;
// Filter by inputValue prefix/fuzzy match
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
const filtered = filterCommands(commands, query);
if (filtered.length === 0) return null;
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
const selected = filtered[clampedIndex];
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
{filtered.slice(0, 8).map((cmd, i) => (
<Box key={`${cmd.execution}-${cmd.name}`}>
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
</Text>
{cmd.aliases.length > 0 && (
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
)}
<Text color="gray"> {cmd.description}</Text>
</Box>
))}
{selected && selected.args && selected.args.length > 0 && (
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
<Text color="yellow">
/{selected.name} {getArgHint(selected.args)}
</Text>
<Text color="gray"> {selected.description}</Text>
</Box>
)}
</Box>
);
}
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
if (!query) return commands;
const q = query.toLowerCase();
return commands.filter(
(c) =>
c.name.includes(q) ||
c.aliases.some((a) => a.includes(q)) ||
c.description.toLowerCase().includes(q),
);
}
function getArgHint(args: CommandArgDef[]): string {
if (!args || args.length === 0) return '';
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
}

View File

@@ -0,0 +1,225 @@
import React, { useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import type { ParsedCommand, CommandDef } from '@mosaic/types';
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
import { CommandAutocomplete } from './command-autocomplete.js';
import { useInputHistory } from '../hooks/use-input-history.js';
import { useState } from 'react';
export interface InputBarProps {
/** Controlled input value — caller owns the state */
value: string;
onChange: (val: string) => void;
onSubmit: (value: string) => void;
onSystemMessage?: (message: string) => void;
onLocalCommand?: (parsed: ParsedCommand) => void;
onGatewayCommand?: (parsed: ParsedCommand) => void;
isStreaming: boolean;
connected: boolean;
/** Whether this input bar is focused/active (default true). When false,
* keyboard input is not captured — e.g. when the sidebar has focus. */
focused?: boolean;
placeholder?: string;
allCommands?: CommandDef[];
}
export function InputBar({
value: input,
onChange: setInput,
onSubmit,
onSystemMessage,
onLocalCommand,
onGatewayCommand,
isStreaming,
connected,
focused = true,
placeholder: placeholderOverride,
allCommands,
}: InputBarProps) {
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
// Determine which commands to show in autocomplete
const availableCommands = allCommands ?? commandRegistry.getAll();
const handleChange = useCallback(
(value: string) => {
setInput(value);
if (value.startsWith('/')) {
setShowAutocomplete(true);
setAutocompleteIndex(0);
} else {
setShowAutocomplete(false);
}
},
[setInput],
);
const handleSubmit = useCallback(
(value: string) => {
if (!value.trim() || isStreaming || !connected) return;
const trimmed = value.trim();
addToHistory(trimmed);
setShowAutocomplete(false);
setAutocompleteIndex(0);
if (trimmed.startsWith('/')) {
const parsed = parseSlashCommand(trimmed);
if (!parsed) {
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
return;
}
const def = commandRegistry.find(parsed.command);
if (!def) {
onSystemMessage?.(
`Unknown command: /${parsed.command}. Type /help for available commands.`,
);
setInput('');
return;
}
if (def.execution === 'local') {
onLocalCommand?.(parsed);
setInput('');
return;
}
// Gateway-executed commands
onGatewayCommand?.(parsed);
setInput('');
return;
}
onSubmit(value);
setInput('');
},
[
onSubmit,
onSystemMessage,
onLocalCommand,
onGatewayCommand,
isStreaming,
connected,
addToHistory,
setInput,
],
);
// Handle Tab: fill in selected autocomplete command
const fillAutocompleteSelection = useCallback(() => {
if (!showAutocomplete) return false;
const query = input.startsWith('/') ? input.slice(1) : input;
const filtered = availableCommands.filter(
(c) =>
!query ||
c.name.includes(query.toLowerCase()) ||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
c.description.toLowerCase().includes(query.toLowerCase()),
);
if (filtered.length === 0) return false;
const idx = Math.min(autocompleteIndex, filtered.length - 1);
const selected = filtered[idx];
if (selected) {
setInput(`/${selected.name} `);
setShowAutocomplete(false);
setAutocompleteIndex(0);
return true;
}
return false;
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
useInput(
(_ch, key) => {
if (key.escape && showAutocomplete) {
setShowAutocomplete(false);
setAutocompleteIndex(0);
return;
}
// Tab: fill autocomplete selection
if (key.tab) {
fillAutocompleteSelection();
return;
}
// Up arrow
if (key.upArrow) {
if (showAutocomplete) {
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
} else {
const prev = navigateUp(input);
if (prev !== null) {
setInput(prev);
if (prev.startsWith('/')) setShowAutocomplete(true);
}
}
return;
}
// Down arrow
if (key.downArrow) {
if (showAutocomplete) {
const query = input.startsWith('/') ? input.slice(1) : input;
const filteredLen = availableCommands.filter(
(c) =>
!query ||
c.name.includes(query.toLowerCase()) ||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
c.description.toLowerCase().includes(query.toLowerCase()),
).length;
const maxVisible = Math.min(filteredLen, 8);
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
} else {
const next = navigateDown();
if (next !== null) {
setInput(next);
setShowAutocomplete(next.startsWith('/'));
}
}
return;
}
// Return/Enter on autocomplete: fill selected command
if (key.return && showAutocomplete) {
fillAutocompleteSelection();
return;
}
},
{ isActive: focused },
);
const placeholder =
placeholderOverride ??
(!connected
? 'disconnected — waiting for gateway…'
: isStreaming
? 'waiting for response…'
: 'message mosaic…');
return (
<Box flexDirection="column">
{showAutocomplete && (
<CommandAutocomplete
commands={availableCommands}
selectedIndex={autocompleteIndex}
inputValue={input}
/>
)}
<Box paddingX={1} borderStyle="single" borderColor="gray">
<Text bold color="green">
{' '}
</Text>
<TextInput
value={input}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={focused}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,192 @@
import React from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Message, ToolCall } from '../hooks/use-socket.js';
export interface MessageListProps {
messages: Message[];
isStreaming: boolean;
currentStreamText: string;
currentThinkingText: string;
activeToolCalls: ToolCall[];
scrollOffset?: number;
viewportSize?: number;
isScrolledUp?: boolean;
highlightedMessageIndices?: Set<number>;
currentHighlightIndex?: number;
}
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function SystemMessageBubble({ msg }: { msg: Message }) {
return (
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
<Text dimColor>{'⚙ '}</Text>
<Text dimColor wrap="wrap">
{msg.content}
</Text>
</Box>
);
}
function MessageBubble({
msg,
highlight,
}: {
msg: Message;
highlight?: 'match' | 'current' | undefined;
}) {
if (msg.role === 'system') {
return <SystemMessageBubble msg={msg} />;
}
const isUser = msg.role === 'user';
const prefix = isUser ? '' : '◆';
const color = isUser ? 'green' : 'cyan';
const borderIndicator =
highlight === 'current' ? (
<Text color="yellowBright" bold>
{' '}
</Text>
) : highlight === 'match' ? (
<Text color="yellow"> </Text>
) : null;
return (
<Box flexDirection="row" marginBottom={1}>
{borderIndicator}
<Box flexDirection="column">
<Box>
<Text bold color={color}>
{prefix}{' '}
</Text>
<Text bold color={color}>
{isUser ? 'you' : 'assistant'}
</Text>
<Text dimColor> {formatTime(msg.timestamp)}</Text>
</Box>
<Box marginLeft={2}>
<Text wrap="wrap">{msg.content}</Text>
</Box>
</Box>
</Box>
);
}
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
const color =
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
return (
<Box marginLeft={2}>
{toolCall.status === 'running' ? (
<Text color="yellow">
<Spinner type="dots" />
</Text>
) : (
<Text color={color}>{icon}</Text>
)}
<Text dimColor> tool: </Text>
<Text color={color}>{toolCall.toolName}</Text>
</Box>
);
}
export function MessageList({
messages,
isStreaming,
currentStreamText,
currentThinkingText,
activeToolCalls,
scrollOffset,
viewportSize,
isScrolledUp,
highlightedMessageIndices,
currentHighlightIndex,
}: MessageListProps) {
const useSlicing = scrollOffset != null && viewportSize != null;
const visibleMessages = useSlicing
? messages.slice(scrollOffset, scrollOffset + viewportSize)
: messages;
const hiddenAbove = useSlicing ? scrollOffset : 0;
return (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{isScrolledUp && hiddenAbove > 0 && (
<Box justifyContent="center">
<Text dimColor> {hiddenAbove} more messages </Text>
</Box>
)}
{messages.length === 0 && !isStreaming && (
<Box justifyContent="center" marginY={1}>
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
</Box>
)}
{visibleMessages.map((msg, i) => {
const globalIndex = hiddenAbove + i;
const highlight =
globalIndex === currentHighlightIndex
? ('current' as const)
: highlightedMessageIndices?.has(globalIndex)
? ('match' as const)
: undefined;
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
})}
{/* Active thinking */}
{isStreaming && currentThinkingText && (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
<Text dimColor italic>
💭 {currentThinkingText}
</Text>
</Box>
)}
{/* Active tool calls */}
{activeToolCalls.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
{activeToolCalls.map((tc) => (
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
))}
</Box>
)}
{/* Streaming response */}
{isStreaming && currentStreamText && (
<Box flexDirection="column" marginBottom={1}>
<Box>
<Text bold color="cyan">
{' '}
</Text>
<Text bold color="cyan">
assistant
</Text>
</Box>
<Box marginLeft={2}>
<Text wrap="wrap">{currentStreamText}</Text>
</Box>
</Box>
)}
{/* Waiting spinner */}
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
<Box marginLeft={2}>
<Text color="cyan">
<Spinner type="dots" />
</Text>
<Text dimColor> thinking</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
export interface SearchBarProps {
query: string;
onQueryChange: (q: string) => void;
totalMatches: number;
currentMatch: number;
onNext: () => void;
onPrev: () => void;
onClose: () => void;
focused: boolean;
}
export function SearchBar({
query,
onQueryChange,
totalMatches,
currentMatch,
onNext,
onPrev,
onClose,
focused,
}: SearchBarProps) {
useInput(
(_input, key) => {
if (key.upArrow) {
onPrev();
}
if (key.downArrow) {
onNext();
}
if (key.escape) {
onClose();
}
},
{ isActive: focused },
);
const borderColor = focused ? 'yellow' : 'gray';
const matchDisplay =
query.length >= 2
? totalMatches > 0
? `${String(currentMatch + 1)}/${String(totalMatches)}`
: 'no matches'
: '';
return (
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
<Text>🔍</Text>
<Box flexGrow={1}>
<TextInput value={query} onChange={onQueryChange} focus={focused} />
</Box>
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
<Text dimColor> navigate · Esc close</Text>
</Box>
);
}

View File

@@ -0,0 +1,143 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import type { ConversationSummary } from '../hooks/use-conversations.js';
export interface SidebarProps {
conversations: ConversationSummary[];
activeConversationId: string | undefined;
selectedIndex: number;
onSelectIndex: (index: number) => void;
onSwitchConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
loading: boolean;
focused: boolean;
width: number;
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
if (diffDays < 7) {
return `${diffDays}d ago`;
}
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const mon = months[date.getMonth()];
const dd = String(date.getDate()).padStart(2, '0');
return `${mon} ${dd}`;
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 1) + '…';
}
export function Sidebar({
conversations,
activeConversationId,
selectedIndex,
onSelectIndex,
onSwitchConversation,
onDeleteConversation,
loading,
focused,
width,
}: SidebarProps) {
useInput(
(_input, key) => {
if (key.upArrow) {
onSelectIndex(Math.max(0, selectedIndex - 1));
}
if (key.downArrow) {
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
}
if (key.return) {
const conv = conversations[selectedIndex];
if (conv) {
onSwitchConversation(conv.id);
}
}
if (_input === 'd') {
const conv = conversations[selectedIndex];
if (conv) {
onDeleteConversation(conv.id);
}
}
},
{ isActive: focused },
);
const borderColor = focused ? 'cyan' : 'gray';
// Available width for content inside border + padding
const innerWidth = width - 4; // 2 border + 2 padding
return (
<Box
flexDirection="column"
width={width}
borderStyle="single"
borderColor={borderColor}
paddingX={1}
>
<Text bold color="cyan">
Conversations
</Text>
<Box marginTop={0} flexDirection="column" flexGrow={1}>
{loading && conversations.length === 0 ? (
<Text dimColor>Loading</Text>
) : conversations.length === 0 ? (
<Text dimColor>No conversations</Text>
) : (
conversations.map((conv, idx) => {
const isActive = conv.id === activeConversationId;
const isSelected = idx === selectedIndex && focused;
const marker = isActive ? '● ' : ' ';
const time = formatRelativeTime(conv.updatedAt);
const title = conv.title ?? 'Untitled';
// marker(2) + title + space(1) + time
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
const displayTitle = truncate(title, maxTitleLen);
return (
<Box key={conv.id}>
<Text
inverse={isSelected}
color={isActive ? 'cyan' : undefined}
dimColor={!isActive && !isSelected}
>
{marker}
{displayTitle}
{' '.repeat(
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
)}
{time}
</Text>
</Box>
);
})
)}
</Box>
{focused && <Text dimColor> navigate enter switch d delete</Text>}
</Box>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Box, Text } from 'ink';
export interface TopBarProps {
gatewayUrl: string;
version: string;
modelName: string | null;
thinkingLevel: string;
contextWindow: number;
agentName: string;
connected: boolean;
connecting: boolean;
}
/** Compact the URL — strip protocol */
function compactHost(url: string): string {
return url.replace(/^https?:\/\//, '');
}
function formatContextWindow(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
return String(n);
}
/**
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
*
* Layout:
* blue ·· purple
* ·· pink ··
* amber ·· teal
*/
// Two-space gap between tiles (extracted to avoid prettier collapse)
const GAP = ' ';
function MosaicIcon() {
return (
<Box flexDirection="column" marginRight={2}>
<Text>
<Text color="#2f80ff"></Text>
<Text>{GAP}</Text>
<Text color="#8b5cf6"></Text>
</Text>
<Text>
<Text>{GAP}</Text>
<Text color="#ec4899"></Text>
</Text>
<Text>
<Text color="#f59e0b"></Text>
<Text>{GAP}</Text>
<Text color="#14b8a6"></Text>
</Text>
</Box>
);
}
export function TopBar({
gatewayUrl,
version,
modelName,
thinkingLevel,
contextWindow,
agentName,
connected,
connecting,
}: TopBarProps) {
const host = compactHost(gatewayUrl);
const connectionIndicator = connected ? '●' : '○';
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
// Build model description line like: "claude-opus-4-6 (1M context) · default"
const modelDisplay = modelName ?? 'awaiting model';
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
return (
<Box paddingX={1} paddingY={0} marginBottom={1}>
<MosaicIcon />
<Box flexDirection="column" flexGrow={1}>
<Text>
<Text bold color="#56a0ff">
Mosaic Stack
</Text>
<Text dimColor> v{version}</Text>
</Text>
<Text dimColor>
{modelDisplay}
{contextStr}
{thinkingStr} · {agentName}
</Text>
<Text>
<Text color={connectionColor}>{connectionIndicator}</Text>
<Text dimColor> {host}</Text>
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,202 @@
/**
* File reference expansion for TUI chat input.
*
* Detects @path/to/file patterns in user messages, reads the file contents,
* and inlines them as fenced code blocks in the message.
*
* Supports:
* - @relative/path.ts
* - @./relative/path.ts
* - @/absolute/path.ts
* - @~/home-relative/path.ts
*
* Also provides an /attach <path> command handler.
*/
import { readFile, stat } from 'node:fs/promises';
import { resolve, extname, basename } from 'node:path';
import { homedir } from 'node:os';
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
const MAX_FILES_PER_MESSAGE = 10;
/**
* Regex to detect @file references in user input.
* Matches @<path> where path starts with /, ./, ~/, or a word char,
* and continues until whitespace or end of string.
* Excludes @mentions that look like usernames (no dots/slashes).
*/
const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g;
interface FileRefResult {
/** The expanded message text with file contents inlined */
expandedMessage: string;
/** Files that were successfully read */
filesAttached: string[];
/** Errors encountered while reading files */
errors: string[];
}
function resolveFilePath(ref: string): string {
if (ref.startsWith('~/')) {
return resolve(homedir(), ref.slice(2));
}
return resolve(process.cwd(), ref);
}
function getLanguageHint(filePath: string): string {
const ext = extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.py': 'python',
'.rb': 'ruby',
'.rs': 'rust',
'.go': 'go',
'.java': 'java',
'.c': 'c',
'.cpp': 'cpp',
'.h': 'c',
'.hpp': 'cpp',
'.cs': 'csharp',
'.sh': 'bash',
'.bash': 'bash',
'.zsh': 'zsh',
'.fish': 'fish',
'.json': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.xml': 'xml',
'.html': 'html',
'.css': 'css',
'.scss': 'scss',
'.md': 'markdown',
'.sql': 'sql',
'.graphql': 'graphql',
'.dockerfile': 'dockerfile',
'.tf': 'terraform',
'.vue': 'vue',
'.svelte': 'svelte',
};
return map[ext] ?? '';
}
/**
* Check if the input contains any @file references.
*/
export function hasFileRefs(input: string): boolean {
FILE_REF_PATTERN.lastIndex = 0;
return FILE_REF_PATTERN.test(input);
}
/**
* Expand @file references in a message by reading file contents
* and appending them as fenced code blocks.
*/
export async function expandFileRefs(input: string): Promise<FileRefResult> {
const refs: string[] = [];
FILE_REF_PATTERN.lastIndex = 0;
let match;
while ((match = FILE_REF_PATTERN.exec(input)) !== null) {
const ref = match[1]!;
if (!refs.includes(ref)) {
refs.push(ref);
}
}
if (refs.length === 0) {
return { expandedMessage: input, filesAttached: [], errors: [] };
}
if (refs.length > MAX_FILES_PER_MESSAGE) {
return {
expandedMessage: input,
filesAttached: [],
errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`],
};
}
const filesAttached: string[] = [];
const errors: string[] = [];
const attachments: string[] = [];
for (const ref of refs) {
const filePath = resolveFilePath(ref);
try {
const info = await stat(filePath);
if (!info.isFile()) {
errors.push(`@${ref}: not a file`);
continue;
}
if (info.size > MAX_FILE_SIZE) {
errors.push(
`@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
);
continue;
}
const content = await readFile(filePath, 'utf8');
const lang = getLanguageHint(filePath);
const name = basename(filePath);
attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``);
filesAttached.push(ref);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Only report meaningful errors — ENOENT is common for false @mention matches
if (msg.includes('ENOENT')) {
// Check if this looks like a file path (has extension or slash)
if (ref.includes('/') || ref.includes('.')) {
errors.push(`@${ref}: file not found`);
}
// Otherwise silently skip — likely an @mention, not a file ref
} else {
errors.push(`@${ref}: ${msg}`);
}
}
}
if (attachments.length === 0) {
return { expandedMessage: input, filesAttached, errors };
}
const expandedMessage = input + '\n' + attachments.join('\n');
return { expandedMessage, filesAttached, errors };
}
/**
* Handle the /attach <path> command.
* Reads a file and returns the content formatted for inclusion in the chat.
*/
export async function handleAttachCommand(
args: string,
): Promise<{ content: string; error?: string }> {
const filePath = args.trim();
if (!filePath) {
return { content: '', error: 'Usage: /attach <file-path>' };
}
const resolved = resolveFilePath(filePath);
try {
const info = await stat(resolved);
if (!info.isFile()) {
return { content: '', error: `Not a file: ${filePath}` };
}
if (info.size > MAX_FILE_SIZE) {
return {
content: '',
error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
};
}
const content = await readFile(resolved, 'utf8');
const lang = getLanguageHint(resolved);
const name = basename(resolved);
return {
content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: '', error: `Failed to read file: ${msg}` };
}
}

View File

@@ -0,0 +1,438 @@
/**
* Minimal gateway REST API client for the TUI and CLI commands.
*/
export interface ModelInfo {
id: string;
provider: string;
name: string;
}
export interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
export interface SessionInfo {
id: string;
provider: string;
modelId: string;
createdAt: string;
promptCount: number;
channels: string[];
durationMs: number;
}
export interface SessionListResult {
sessions: SessionInfo[];
total: number;
}
// ── Agent Config types ──
export interface AgentConfigInfo {
id: string;
name: string;
provider: string;
model: string;
status: string;
projectId: string | null;
ownerId: string | null;
systemPrompt: string | null;
allowedTools: string[] | null;
skills: string[] | null;
isSystem: boolean;
config: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
// ── Project types ──
export interface ProjectInfo {
id: string;
name: string;
description: string | null;
status: string;
ownerId: string | null;
createdAt: string;
updatedAt: string;
}
// ── Mission types ──
export interface MissionInfo {
id: string;
name: string;
description: string | null;
status: string;
projectId: string | null;
userId: string | null;
phase: string | null;
milestones: Record<string, unknown>[] | null;
config: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
// ── Mission Task types ──
export interface MissionTaskInfo {
id: string;
missionId: string;
taskId: string | null;
userId: string;
status: string;
description: string | null;
notes: string | null;
pr: string | null;
createdAt: string;
updatedAt: string;
}
// ── Helpers ──
function headers(sessionCookie: string, gatewayUrl: string) {
return { Cookie: sessionCookie, Origin: gatewayUrl };
}
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
}
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
}
return (await res.json()) as T;
}
// ── Conversation types ──
export interface ConversationInfo {
id: string;
title: string | null;
archived: boolean;
createdAt: string;
updatedAt: string;
}
// ── Conversation endpoints ──
export async function createConversation(
gatewayUrl: string,
sessionCookie: string,
data: { title?: string; projectId?: string } = {},
): Promise<ConversationInfo> {
const res = await fetch(`${gatewayUrl}/api/conversations`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
}
// ── Provider / Model endpoints ──
export async function fetchAvailableModels(
gatewayUrl: string,
sessionCookie?: string,
): Promise<ModelInfo[]> {
try {
const res = await fetch(`${gatewayUrl}/api/providers/models`, {
headers: {
...(sessionCookie ? { Cookie: sessionCookie } : {}),
Origin: gatewayUrl,
},
});
if (!res.ok) return [];
const data = (await res.json()) as ModelInfo[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export async function fetchProviders(
gatewayUrl: string,
sessionCookie?: string,
): Promise<ProviderInfo[]> {
try {
const res = await fetch(`${gatewayUrl}/api/providers`, {
headers: {
...(sessionCookie ? { Cookie: sessionCookie } : {}),
Origin: gatewayUrl,
},
});
if (!res.ok) return [];
const data = (await res.json()) as ProviderInfo[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
// ── Session endpoints ──
export async function fetchSessions(
gatewayUrl: string,
sessionCookie: string,
): Promise<SessionListResult> {
const res = await fetch(`${gatewayUrl}/api/sessions`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
}
export async function deleteSession(
gatewayUrl: string,
sessionCookie: string,
sessionId: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
}
}
// ── Agent Config endpoints ──
export async function fetchAgentConfigs(
gatewayUrl: string,
sessionCookie: string,
): Promise<AgentConfigInfo[]> {
const res = await fetch(`${gatewayUrl}/api/agents`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
}
export async function fetchAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
}
export async function createAgentConfig(
gatewayUrl: string,
sessionCookie: string,
data: {
name: string;
provider: string;
model: string;
projectId?: string;
systemPrompt?: string;
allowedTools?: string[];
skills?: string[];
config?: Record<string, unknown>;
},
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
}
export async function updateAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
data: Record<string, unknown>,
): Promise<AgentConfigInfo> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
}
export async function deleteAgentConfig(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
}
}
// ── Project endpoints ──
export async function fetchProjects(
gatewayUrl: string,
sessionCookie: string,
): Promise<ProjectInfo[]> {
const res = await fetch(`${gatewayUrl}/api/projects`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
}
// ── Mission endpoints ──
export async function fetchMissions(
gatewayUrl: string,
sessionCookie: string,
): Promise<MissionInfo[]> {
const res = await fetch(`${gatewayUrl}/api/missions`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
}
export async function fetchMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionInfo>(res, 'Failed to get mission');
}
export async function createMission(
gatewayUrl: string,
sessionCookie: string,
data: {
name: string;
description?: string;
projectId?: string;
status?: string;
phase?: string;
milestones?: Record<string, unknown>[];
config?: Record<string, unknown>;
},
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionInfo>(res, 'Failed to create mission');
}
export async function updateMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
data: Record<string, unknown>,
): Promise<MissionInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionInfo>(res, 'Failed to update mission');
}
export async function deleteMission(
gatewayUrl: string,
sessionCookie: string,
id: string,
): Promise<void> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: headers(sessionCookie, gatewayUrl),
});
if (!res.ok && res.status !== 204) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
}
}
// ── Conversation Message types ──
export interface ConversationMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
createdAt: string;
}
// ── Conversation Message endpoints ──
export async function fetchConversationMessages(
gatewayUrl: string,
sessionCookie: string,
conversationId: string,
): Promise<ConversationMessage[]> {
const res = await fetch(
`${gatewayUrl}/api/conversations/${encodeURIComponent(conversationId)}/messages`,
{
headers: headers(sessionCookie, gatewayUrl),
},
);
return handleResponse<ConversationMessage[]>(res, 'Failed to fetch conversation messages');
}
// ── Mission Task endpoints ──
export async function fetchMissionTasks(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
): Promise<MissionTaskInfo[]> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
headers: headers(sessionCookie, gatewayUrl),
});
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
}
export async function createMissionTask(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
data: {
description?: string;
status?: string;
notes?: string;
pr?: string;
taskId?: string;
},
): Promise<MissionTaskInfo> {
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
method: 'POST',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
});
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
}
export async function updateMissionTask(
gatewayUrl: string,
sessionCookie: string,
missionId: string,
taskId: string,
data: Record<string, unknown>,
): Promise<MissionTaskInfo> {
const res = await fetch(
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
{
method: 'PATCH',
headers: jsonHeaders(sessionCookie, gatewayUrl),
body: JSON.stringify(data),
},
);
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
}

View File

@@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
export type AppMode = 'chat' | 'sidebar' | 'search';
export interface UseAppModeReturn {
mode: AppMode;
setMode: (mode: AppMode) => void;
toggleSidebar: () => void;
sidebarOpen: boolean;
}
export function useAppMode(): UseAppModeReturn {
const [mode, setModeState] = useState<AppMode>('chat');
const [sidebarOpen, setSidebarOpen] = useState(false);
const setMode = useCallback((next: AppMode) => {
setModeState(next);
if (next === 'sidebar') {
setSidebarOpen(true);
}
}, []);
const toggleSidebar = useCallback(() => {
setSidebarOpen((prev) => {
if (prev) {
// Closing sidebar — return to chat
setModeState('chat');
return false;
}
// Opening sidebar — set mode to sidebar
setModeState('sidebar');
return true;
});
}, []);
return { mode, setMode, toggleSidebar, sidebarOpen };
}

View File

@@ -0,0 +1,143 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export interface ConversationSummary {
id: string;
title: string | null;
archived: boolean;
createdAt: string;
updatedAt: string;
}
export interface UseConversationsOptions {
gatewayUrl: string;
sessionCookie?: string;
}
export interface UseConversationsReturn {
conversations: ConversationSummary[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
createConversation: (title?: string) => Promise<ConversationSummary | null>;
deleteConversation: (id: string) => Promise<boolean>;
renameConversation: (id: string, title: string) => Promise<boolean>;
}
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
const { gatewayUrl, sessionCookie } = opts;
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
const headers = useCallback(
(includeContentType = true): Record<string, string> => {
const h: Record<string, string> = { Origin: gatewayUrl };
if (includeContentType) h['Content-Type'] = 'application/json';
if (sessionCookie) h['Cookie'] = sessionCookie;
return h;
},
[gatewayUrl, sessionCookie],
);
const refresh = useCallback(async () => {
if (!mountedRef.current) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as ConversationSummary[];
if (mountedRef.current) {
setConversations(data);
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (mountedRef.current) {
setLoading(false);
}
}
}, [gatewayUrl, headers]);
useEffect(() => {
mountedRef.current = true;
void refresh();
return () => {
mountedRef.current = false;
};
}, [refresh]);
const createConversation = useCallback(
async (title?: string): Promise<ConversationSummary | null> => {
try {
const res = await fetch(`${gatewayUrl}/api/conversations`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ title: title ?? null }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as ConversationSummary;
if (mountedRef.current) {
setConversations((prev) => [data, ...prev]);
}
return data;
} catch {
return null;
}
},
[gatewayUrl, headers],
);
const deleteConversation = useCallback(
async (id: string): Promise<boolean> => {
try {
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
method: 'DELETE',
headers: headers(false),
});
if (!res.ok) return false;
if (mountedRef.current) {
setConversations((prev) => prev.filter((c) => c.id !== id));
}
return true;
} catch {
return false;
}
},
[gatewayUrl, headers],
);
const renameConversation = useCallback(
async (id: string, title: string): Promise<boolean> => {
try {
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
method: 'PATCH',
headers: headers(),
body: JSON.stringify({ title }),
});
if (!res.ok) return false;
if (mountedRef.current) {
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
}
return true;
} catch {
return false;
}
},
[gatewayUrl, headers],
);
return {
conversations,
loading,
error,
refresh,
createConversation,
deleteConversation,
renameConversation,
};
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { execSync } from 'node:child_process';
export interface GitInfo {
branch: string | null;
cwd: string;
}
export function useGitInfo(): GitInfo {
const [info, setInfo] = useState<GitInfo>({
branch: null,
cwd: process.cwd(),
});
useEffect(() => {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf-8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
setInfo({ branch, cwd: process.cwd() });
} catch {
setInfo({ branch: null, cwd: process.cwd() });
}
}, []);
return info;
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
/**
* Tests for input history logic extracted from useInputHistory.
* We test the pure state transitions directly rather than using
* React testing utilities to avoid react-dom version conflicts.
*/
const MAX_HISTORY = 50;
function createHistoryState() {
let history: string[] = [];
let historyIndex = -1;
let savedInput = '';
function addToHistory(input: string): void {
if (!input.trim()) return;
if (history[0] === input) return;
history = [input, ...history].slice(0, MAX_HISTORY);
historyIndex = -1;
}
function navigateUp(currentInput: string): string | null {
if (history.length === 0) return null;
if (historyIndex === -1) {
savedInput = currentInput;
}
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
historyIndex = nextIndex;
return history[nextIndex] ?? null;
}
function navigateDown(): string | null {
if (historyIndex <= 0) {
historyIndex = -1;
return savedInput;
}
const nextIndex = historyIndex - 1;
historyIndex = nextIndex;
return history[nextIndex] ?? null;
}
function resetNavigation(): void {
historyIndex = -1;
}
function getHistoryLength(): number {
return history.length;
}
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
}
describe('useInputHistory (logic)', () => {
let h: ReturnType<typeof createHistoryState>;
beforeEach(() => {
h = createHistoryState();
});
it('adds to history on submit', () => {
h.addToHistory('hello');
h.addToHistory('world');
// navigateUp should return 'world' first (most recent)
const val = h.navigateUp('');
expect(val).toBe('world');
});
it('does not add empty strings to history', () => {
h.addToHistory('');
h.addToHistory(' ');
const val = h.navigateUp('');
expect(val).toBeNull();
});
it('navigateDown after up returns saved input', () => {
h.addToHistory('first');
const up = h.navigateUp('current');
expect(up).toBe('first');
const down = h.navigateDown();
expect(down).toBe('current');
});
it('does not add duplicate consecutive entries', () => {
h.addToHistory('same');
h.addToHistory('same');
expect(h.getHistoryLength()).toBe(1);
});
it('caps history at MAX_HISTORY entries', () => {
for (let i = 0; i < 55; i++) {
h.addToHistory(`entry-${i}`);
}
expect(h.getHistoryLength()).toBe(50);
// Navigate to the oldest entry
let val: string | null = null;
for (let i = 0; i < 60; i++) {
val = h.navigateUp('');
}
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
expect(val).toBe('entry-5');
});
it('navigateUp returns null when history is empty', () => {
const val = h.navigateUp('something');
expect(val).toBeNull();
});
it('navigateUp cycles through multiple entries', () => {
h.addToHistory('a');
h.addToHistory('b');
h.addToHistory('c');
expect(h.navigateUp('')).toBe('c');
expect(h.navigateUp('c')).toBe('b');
expect(h.navigateUp('b')).toBe('a');
});
it('resetNavigation resets index to -1', () => {
h.addToHistory('test');
h.navigateUp('');
h.resetNavigation();
// After reset, navigateUp from index -1 returns most recent again
const val = h.navigateUp('');
expect(val).toBe('test');
});
});

View File

@@ -0,0 +1,48 @@
import { useState, useCallback } from 'react';
const MAX_HISTORY = 50;
export function useInputHistory() {
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [savedInput, setSavedInput] = useState<string>('');
const addToHistory = useCallback((input: string) => {
if (!input.trim()) return;
setHistory((prev) => {
// Avoid duplicate consecutive entries
if (prev[0] === input) return prev;
return [input, ...prev].slice(0, MAX_HISTORY);
});
setHistoryIndex(-1);
}, []);
const navigateUp = useCallback(
(currentInput: string): string | null => {
if (history.length === 0) return null;
if (historyIndex === -1) {
setSavedInput(currentInput);
}
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
setHistoryIndex(nextIndex);
return history[nextIndex] ?? null;
},
[history, historyIndex],
);
const navigateDown = useCallback((): string | null => {
if (historyIndex <= 0) {
setHistoryIndex(-1);
return savedInput;
}
const nextIndex = historyIndex - 1;
setHistoryIndex(nextIndex);
return history[nextIndex] ?? null;
}, [history, historyIndex, savedInput]);
const resetNavigation = useCallback(() => {
setHistoryIndex(-1);
}, []);
return { addToHistory, navigateUp, navigateDown, resetNavigation };
}

View File

@@ -0,0 +1,76 @@
import { useState, useMemo, useCallback } from 'react';
import type { Message } from './use-socket.js';
export interface SearchMatch {
messageIndex: number;
charOffset: number;
}
export interface UseSearchReturn {
query: string;
setQuery: (q: string) => void;
matches: SearchMatch[];
currentMatchIndex: number;
nextMatch: () => void;
prevMatch: () => void;
clear: () => void;
totalMatches: number;
}
export function useSearch(messages: Message[]): UseSearchReturn {
const [query, setQuery] = useState('');
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const matches = useMemo<SearchMatch[]>(() => {
if (query.length < 2) return [];
const lowerQuery = query.toLowerCase();
const result: SearchMatch[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg) continue;
const content = msg.content.toLowerCase();
let offset = 0;
while (true) {
const idx = content.indexOf(lowerQuery, offset);
if (idx === -1) break;
result.push({ messageIndex: i, charOffset: idx });
offset = idx + 1;
}
}
return result;
}, [query, messages]);
// Reset match index when matches change
useMemo(() => {
setCurrentMatchIndex(0);
}, [matches]);
const nextMatch = useCallback(() => {
if (matches.length === 0) return;
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
}, [matches.length]);
const prevMatch = useCallback(() => {
if (matches.length === 0) return;
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
}, [matches.length]);
const clear = useCallback(() => {
setQuery('');
setCurrentMatchIndex(0);
}, []);
return {
query,
setQuery,
matches,
currentMatchIndex,
nextMatch,
prevMatch,
clear,
totalMatches: matches.length,
};
}

View File

@@ -0,0 +1,339 @@
import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react';
import { io, type Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
MessageAckPayload,
AgentEndPayload,
AgentTextPayload,
AgentThinkingPayload,
ToolStartPayload,
ToolEndPayload,
SessionInfoPayload,
ErrorPayload,
CommandManifestPayload,
SlashCommandResultPayload,
SystemReloadPayload,
RoutingDecisionInfo,
} from '@mosaic/types';
import { commandRegistry } from '../commands/index.js';
export interface ToolCall {
toolCallId: string;
toolName: string;
status: 'running' | 'success' | 'error';
}
export interface Message {
role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
}
export interface TokenUsage {
input: number;
output: number;
total: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextPercent: number;
contextWindow: number;
}
export interface UseSocketOptions {
gatewayUrl: string;
sessionCookie?: string;
initialConversationId?: string;
initialModel?: string;
initialProvider?: string;
agentId?: string;
}
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
export interface UseSocketReturn {
connected: boolean;
connecting: boolean;
messages: Message[];
conversationId: string | undefined;
isStreaming: boolean;
currentStreamText: string;
currentThinkingText: string;
activeToolCalls: ToolCall[];
tokenUsage: TokenUsage;
modelName: string | null;
providerName: string | null;
thinkingLevel: string;
availableThinkingLevels: string[];
/** Last routing decision received from the gateway (M4-008) */
routingDecision: RoutingDecisionInfo | null;
sendMessage: (content: string) => void;
addSystemMessage: (content: string) => void;
setThinkingLevel: (level: string) => void;
switchConversation: (id: string) => void;
clearMessages: () => void;
connectionError: string | null;
socketRef: MutableRefObject<TypedSocket | null>;
}
const EMPTY_USAGE: TokenUsage = {
input: 0,
output: 0,
total: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
contextPercent: 0,
contextWindow: 0,
};
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
const {
gatewayUrl,
sessionCookie,
initialConversationId,
initialModel,
initialProvider,
agentId,
} = opts;
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(true);
const [messages, setMessages] = useState<Message[]>([]);
const [conversationId, setConversationId] = useState(initialConversationId);
const [isStreaming, setIsStreaming] = useState(false);
const [currentStreamText, setCurrentStreamText] = useState('');
const [currentThinkingText, setCurrentThinkingText] = useState('');
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
const [modelName, setModelName] = useState<string | null>(null);
const [providerName, setProviderName] = useState<string | null>(null);
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const socketRef = useRef<TypedSocket | null>(null);
const conversationIdRef = useRef(conversationId);
conversationIdRef.current = conversationId;
useEffect(() => {
const socket = io(`${gatewayUrl}/chat`, {
transports: ['websocket'],
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
reconnection: true,
reconnectionDelay: 2000,
reconnectionAttempts: Infinity,
}) as TypedSocket;
socketRef.current = socket;
socket.on('connect', () => {
setConnected(true);
setConnecting(false);
setConnectionError(null);
});
socket.on('disconnect', () => {
setConnected(false);
setIsStreaming(false);
setCurrentStreamText('');
setCurrentThinkingText('');
setActiveToolCalls([]);
});
socket.io.on('error', (err: Error) => {
setConnecting(false);
setConnectionError(err.message);
});
socket.on('message:ack', (data: MessageAckPayload) => {
setConversationId(data.conversationId);
});
socket.on('session:info', (data: SessionInfoPayload) => {
setProviderName(data.provider);
setModelName(data.modelId);
setThinkingLevelState(data.thinkingLevel);
setAvailableThinkingLevels(data.availableThinkingLevels);
// Update routing decision if provided (M4-008)
if (data.routingDecision) {
setRoutingDecision(data.routingDecision);
}
});
socket.on('agent:start', () => {
setIsStreaming(true);
setCurrentStreamText('');
setCurrentThinkingText('');
setActiveToolCalls([]);
});
socket.on('agent:text', (data: AgentTextPayload) => {
setCurrentStreamText((prev) => prev + data.text);
});
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
setCurrentThinkingText((prev) => prev + data.text);
});
socket.on('agent:tool:start', (data: ToolStartPayload) => {
setActiveToolCalls((prev) => [
...prev,
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
]);
});
socket.on('agent:tool:end', (data: ToolEndPayload) => {
setActiveToolCalls((prev) =>
prev.map((tc) =>
tc.toolCallId === data.toolCallId
? { ...tc, status: data.isError ? 'error' : 'success' }
: tc,
),
);
});
socket.on('agent:end', (data: AgentEndPayload) => {
setCurrentStreamText((prev) => {
if (prev) {
setMessages((msgs) => [
...msgs,
{ role: 'assistant', content: prev, timestamp: new Date() },
]);
}
return '';
});
setCurrentThinkingText('');
setActiveToolCalls([]);
setIsStreaming(false);
// Update usage from the payload
if (data.usage) {
setProviderName(data.usage.provider);
setModelName(data.usage.modelId);
setThinkingLevelState(data.usage.thinkingLevel);
setTokenUsage({
input: data.usage.tokens.input,
output: data.usage.tokens.output,
total: data.usage.tokens.total,
cacheRead: data.usage.tokens.cacheRead,
cacheWrite: data.usage.tokens.cacheWrite,
cost: data.usage.cost,
contextPercent: data.usage.context.percent ?? 0,
contextWindow: data.usage.context.window,
});
}
});
socket.on('error', (data: ErrorPayload) => {
setMessages((msgs) => [
...msgs,
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
]);
setIsStreaming(false);
});
socket.on('commands:manifest', (data: CommandManifestPayload) => {
commandRegistry.updateManifest(data.manifest);
});
socket.on('command:result', (data: SlashCommandResultPayload) => {
const prefix = data.success ? '' : 'Error: ';
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
setMessages((msgs) => [
...msgs,
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
]);
});
socket.on('system:reload', (data: SystemReloadPayload) => {
commandRegistry.updateManifest({
commands: data.commands,
skills: data.skills,
version: Date.now(),
});
setMessages((msgs) => [
...msgs,
{ role: 'system', content: data.message, timestamp: new Date() },
]);
});
return () => {
socket.disconnect();
};
}, [gatewayUrl, sessionCookie]);
const sendMessage = useCallback(
(content: string) => {
if (!content.trim() || isStreaming) return;
if (!socketRef.current?.connected) return;
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
socketRef.current.emit('message', {
conversationId,
content,
...(initialProvider ? { provider: initialProvider } : {}),
...(initialModel ? { modelId: initialModel } : {}),
...(agentId ? { agentId } : {}),
});
},
[conversationId, isStreaming],
);
const addSystemMessage = useCallback((content: string) => {
setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]);
}, []);
const setThinkingLevel = useCallback((level: string) => {
const cid = conversationIdRef.current;
if (!socketRef.current?.connected || !cid) return;
socketRef.current.emit('set:thinking', {
conversationId: cid,
level,
});
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
setCurrentStreamText('');
setCurrentThinkingText('');
setActiveToolCalls([]);
setIsStreaming(false);
}, []);
const switchConversation = useCallback(
(id: string) => {
clearMessages();
setConversationId(id);
},
[clearMessages],
);
return {
connected,
connecting,
messages,
conversationId,
isStreaming,
currentStreamText,
currentThinkingText,
activeToolCalls,
tokenUsage,
modelName,
providerName,
thinkingLevel,
availableThinkingLevels,
routingDecision,
sendMessage,
addSystemMessage,
setThinkingLevel,
switchConversation,
clearMessages,
connectionError,
socketRef,
};
}

View File

@@ -0,0 +1,80 @@
import { useState, useCallback, useEffect } from 'react';
import { useStdout } from 'ink';
export interface UseViewportOptions {
totalItems: number;
reservedLines?: number;
}
export interface UseViewportReturn {
scrollOffset: number;
viewportSize: number;
isScrolledUp: boolean;
scrollToBottom: () => void;
scrollBy: (delta: number) => void;
scrollTo: (offset: number) => void;
canScrollUp: boolean;
canScrollDown: boolean;
}
export function useViewport({
totalItems,
reservedLines = 10,
}: UseViewportOptions): UseViewportReturn {
const { stdout } = useStdout();
const rows = stdout?.rows ?? 24;
const viewportSize = Math.max(1, rows - reservedLines);
const [scrollOffset, setScrollOffset] = useState(0);
const [autoFollow, setAutoFollow] = useState(true);
// Compute the maximum valid scroll offset
const maxOffset = Math.max(0, totalItems - viewportSize);
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
useEffect(() => {
if (autoFollow) {
setScrollOffset(maxOffset);
}
}, [autoFollow, maxOffset]);
const scrollTo = useCallback(
(offset: number) => {
const clamped = Math.max(0, Math.min(offset, maxOffset));
setScrollOffset(clamped);
setAutoFollow(clamped >= maxOffset);
},
[maxOffset],
);
const scrollBy = useCallback(
(delta: number) => {
setScrollOffset((prev) => {
const next = Math.max(0, Math.min(prev + delta, maxOffset));
setAutoFollow(next >= maxOffset);
return next;
});
},
[maxOffset],
);
const scrollToBottom = useCallback(() => {
setScrollOffset(maxOffset);
setAutoFollow(true);
}, [maxOffset]);
const isScrolledUp = scrollOffset < maxOffset;
const canScrollUp = scrollOffset > 0;
const canScrollDown = scrollOffset < maxOffset;
return {
scrollOffset,
viewportSize,
isScrolledUp,
scrollToBottom,
scrollBy,
scrollTo,
canScrollUp,
canScrollDown,
};
}

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src",
"jsx": "react-jsx"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/storage", "name": "@mosaic/storage",
"version": "0.0.2", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git", "url": "https://git.mosaicstack.dev/mosaic/mosaic-stack.git",
@@ -21,12 +21,11 @@
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.2.17",
"@mosaic/db": "workspace:^", "@mosaic/db": "workspace:^",
"@mosaic/types": "workspace:*", "@mosaic/types": "workspace:*"
"better-sqlite3": "^12.8.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^2.0.0" "vitest": "^2.0.0"
}, },

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqliteAdapter } from './sqlite.js'; import { PgliteAdapter } from './pglite.js';
describe('SqliteAdapter', () => { describe('PgliteAdapter', () => {
let adapter: SqliteAdapter; let adapter: PgliteAdapter;
beforeEach(async () => { beforeEach(async () => {
adapter = new SqliteAdapter({ type: 'sqlite', path: ':memory:' }); // In-memory PGlite instance — no dataDir = memory mode
adapter = new PgliteAdapter({ type: 'pglite' });
await adapter.migrate(); await adapter.migrate();
}); });
@@ -80,7 +81,7 @@ describe('SqliteAdapter', () => {
it('supports limit and offset', async () => { it('supports limit and offset', async () => {
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await adapter.create('users', { name: `User${i}`, idx: i }); await adapter.create('users', { name: `User${i.toString()}`, idx: i });
} }
const page = await adapter.find('users', undefined, { const page = await adapter.find('users', undefined, {

View File

@@ -0,0 +1,290 @@
import { PGlite } from '@electric-sql/pglite';
import { randomUUID } from 'node:crypto';
import type { StorageAdapter, StorageConfig } from '../types.js';
/* eslint-disable @typescript-eslint/no-explicit-any */
const COLLECTIONS = [
'users',
'sessions',
'accounts',
'projects',
'missions',
'tasks',
'agents',
'conversations',
'messages',
'preferences',
'insights',
'skills',
'events',
'routing_rules',
'provider_credentials',
'agent_logs',
'teams',
'team_members',
'mission_tasks',
'tickets',
'summarization_jobs',
'appreciations',
'verifications',
] as const;
function buildFilterClause(filter?: Record<string, unknown>): {
clause: string;
params: unknown[];
} {
if (!filter || Object.keys(filter).length === 0) return { clause: '', params: [] };
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
for (const [key, value] of Object.entries(filter)) {
if (key === 'id') {
conditions.push(`id = $${paramIdx.toString()}`);
params.push(value);
paramIdx++;
} else {
conditions.push(`data->>'${key}' = $${paramIdx.toString()}`);
params.push(typeof value === 'object' ? JSON.stringify(value) : value);
paramIdx++;
}
}
return { clause: ` WHERE ${conditions.join(' AND ')}`, params };
}
type PgClient = PGlite | { query: PGlite['query'] };
async function pgCreate<T extends Record<string, unknown>>(
pg: PgClient,
collection: string,
data: T,
): Promise<T & { id: string }> {
const id = (data as any).id ?? randomUUID();
const rest = Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id'));
await pg.query(`INSERT INTO ${collection} (id, data) VALUES ($1, $2::jsonb)`, [
id,
JSON.stringify(rest),
]);
return { ...data, id } as T & { id: string };
}
async function pgRead<T extends Record<string, unknown>>(
pg: PgClient,
collection: string,
id: string,
): Promise<T | null> {
const result = await pg.query<{ id: string; data: Record<string, unknown> }>(
`SELECT id, data FROM ${collection} WHERE id = $1`,
[id],
);
const row = result.rows[0];
if (!row) return null;
return { id: row.id, ...(row.data as object) } as unknown as T;
}
async function pgUpdate(
pg: PgClient,
collection: string,
id: string,
data: Record<string, unknown>,
): Promise<boolean> {
const existing = await pg.query<{ data: Record<string, unknown> }>(
`SELECT data FROM ${collection} WHERE id = $1`,
[id],
);
const row = existing.rows[0];
if (!row) return false;
const merged = { ...(row.data as object), ...data };
const result = await pg.query(
`UPDATE ${collection} SET data = $1::jsonb, updated_at = now() WHERE id = $2`,
[JSON.stringify(merged), id],
);
return (result.affectedRows ?? 0) > 0;
}
async function pgDelete(pg: PgClient, collection: string, id: string): Promise<boolean> {
const result = await pg.query(`DELETE FROM ${collection} WHERE id = $1`, [id]);
return (result.affectedRows ?? 0) > 0;
}
async function pgFind<T extends Record<string, unknown>>(
pg: PgClient,
collection: string,
filter?: Record<string, unknown>,
opts?: { limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc' },
): Promise<T[]> {
const { clause, params } = buildFilterClause(filter);
let paramIdx = params.length + 1;
let query = `SELECT id, data FROM ${collection}${clause}`;
if (opts?.orderBy) {
const dir = opts.order === 'desc' ? 'DESC' : 'ASC';
const col =
opts.orderBy === 'id'
? 'id'
: opts.orderBy === 'created_at' || opts.orderBy === 'updated_at'
? opts.orderBy
: `data->>'${opts.orderBy}'`;
query += ` ORDER BY ${col} ${dir}`;
}
if (opts?.limit !== undefined) {
query += ` LIMIT $${paramIdx.toString()}`;
params.push(opts.limit);
paramIdx++;
}
if (opts?.offset !== undefined) {
query += ` OFFSET $${paramIdx.toString()}`;
params.push(opts.offset);
paramIdx++;
}
const result = await pg.query<{ id: string; data: Record<string, unknown> }>(query, params);
return result.rows.map((row) => ({ id: row.id, ...(row.data as object) }) as unknown as T);
}
async function pgCount(
pg: PgClient,
collection: string,
filter?: Record<string, unknown>,
): Promise<number> {
const { clause, params } = buildFilterClause(filter);
const result = await pg.query<{ count: string }>(
`SELECT COUNT(*) as count FROM ${collection}${clause}`,
params,
);
return parseInt(result.rows[0]?.count ?? '0', 10);
}
export class PgliteAdapter implements StorageAdapter {
readonly name = 'pglite';
private pg: PGlite;
constructor(config: Extract<StorageConfig, { type: 'pglite' }>) {
this.pg = new PGlite(config.dataDir);
}
async create<T extends Record<string, unknown>>(
collection: string,
data: T,
): Promise<T & { id: string }> {
return pgCreate(this.pg, collection, data);
}
async read<T extends Record<string, unknown>>(collection: string, id: string): Promise<T | null> {
return pgRead(this.pg, collection, id);
}
async update(collection: string, id: string, data: Record<string, unknown>): Promise<boolean> {
return pgUpdate(this.pg, collection, id, data);
}
async delete(collection: string, id: string): Promise<boolean> {
return pgDelete(this.pg, collection, id);
}
async find<T extends Record<string, unknown>>(
collection: string,
filter?: Record<string, unknown>,
opts?: { limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc' },
): Promise<T[]> {
return pgFind(this.pg, collection, filter, opts);
}
async findOne<T extends Record<string, unknown>>(
collection: string,
filter: Record<string, unknown>,
): Promise<T | null> {
const results = await this.find<T>(collection, filter, { limit: 1 });
return results[0] ?? null;
}
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
return pgCount(this.pg, collection, filter);
}
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
return this.pg.transaction(async (tx) => {
const txAdapter = new PgliteTxAdapter(tx as unknown as PgClient);
return fn(txAdapter);
});
}
async migrate(): Promise<void> {
for (const name of COLLECTIONS) {
await this.pg.query(`
CREATE TABLE IF NOT EXISTS ${name} (
id TEXT PRIMARY KEY,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
}
}
async close(): Promise<void> {
await this.pg.close();
}
}
/**
* Transaction wrapper that delegates to the PGlite transaction connection.
*/
class PgliteTxAdapter implements StorageAdapter {
readonly name = 'pglite';
private pg: PgClient;
constructor(pg: PgClient) {
this.pg = pg;
}
async create<T extends Record<string, unknown>>(
collection: string,
data: T,
): Promise<T & { id: string }> {
return pgCreate(this.pg, collection, data);
}
async read<T extends Record<string, unknown>>(collection: string, id: string): Promise<T | null> {
return pgRead(this.pg, collection, id);
}
async update(collection: string, id: string, data: Record<string, unknown>): Promise<boolean> {
return pgUpdate(this.pg, collection, id, data);
}
async delete(collection: string, id: string): Promise<boolean> {
return pgDelete(this.pg, collection, id);
}
async find<T extends Record<string, unknown>>(
collection: string,
filter?: Record<string, unknown>,
opts?: { limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc' },
): Promise<T[]> {
return pgFind(this.pg, collection, filter, opts);
}
async findOne<T extends Record<string, unknown>>(
collection: string,
filter: Record<string, unknown>,
): Promise<T | null> {
const results = await this.find<T>(collection, filter, { limit: 1 });
return results[0] ?? null;
}
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
return pgCount(this.pg, collection, filter);
}
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
// Already inside a transaction — run directly
return fn(this);
}
async migrate(): Promise<void> {
// No-op inside transaction
}
async close(): Promise<void> {
// No-op inside transaction
}
}

View File

@@ -1,283 +0,0 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import type { StorageAdapter, StorageConfig } from '../types.js';
/* eslint-disable @typescript-eslint/no-explicit-any */
const COLLECTIONS = [
'users',
'sessions',
'accounts',
'projects',
'missions',
'tasks',
'agents',
'conversations',
'messages',
'preferences',
'insights',
'skills',
'events',
'routing_rules',
'provider_credentials',
'agent_logs',
'teams',
'team_members',
'mission_tasks',
'tickets',
'summarization_jobs',
'appreciations',
'verifications',
] as const;
function buildFilterClause(filter?: Record<string, unknown>): {
clause: string;
params: unknown[];
} {
if (!filter || Object.keys(filter).length === 0) return { clause: '', params: [] };
const conditions: string[] = [];
const params: unknown[] = [];
for (const [key, value] of Object.entries(filter)) {
if (key === 'id') {
conditions.push('id = ?');
params.push(value);
} else {
conditions.push(`json_extract(data_json, '$.${key}') = ?`);
params.push(typeof value === 'object' ? JSON.stringify(value) : value);
}
}
return { clause: ` WHERE ${conditions.join(' AND ')}`, params };
}
export class SqliteAdapter implements StorageAdapter {
readonly name = 'sqlite';
private db: Database.Database;
constructor(config: Extract<StorageConfig, { type: 'sqlite' }>) {
this.db = new Database(config.path);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
}
async create<T extends Record<string, unknown>>(
collection: string,
data: T,
): Promise<T & { id: string }> {
const id = (data as any).id ?? randomUUID();
const now = new Date().toISOString();
const rest = Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id'));
this.db
.prepare(
`INSERT INTO ${collection} (id, data_json, created_at, updated_at) VALUES (?, ?, ?, ?)`,
)
.run(id, JSON.stringify(rest), now, now);
return { ...data, id } as T & { id: string };
}
async read<T extends Record<string, unknown>>(collection: string, id: string): Promise<T | null> {
const row = this.db.prepare(`SELECT * FROM ${collection} WHERE id = ?`).get(id) as any;
if (!row) return null;
return { id: row.id, ...JSON.parse(row.data_json as string) } as T;
}
async update(collection: string, id: string, data: Record<string, unknown>): Promise<boolean> {
const existing = this.db
.prepare(`SELECT data_json FROM ${collection} WHERE id = ?`)
.get(id) as any;
if (!existing) return false;
const merged = { ...JSON.parse(existing.data_json as string), ...data };
const now = new Date().toISOString();
const result = this.db
.prepare(`UPDATE ${collection} SET data_json = ?, updated_at = ? WHERE id = ?`)
.run(JSON.stringify(merged), now, id);
return result.changes > 0;
}
async delete(collection: string, id: string): Promise<boolean> {
const result = this.db.prepare(`DELETE FROM ${collection} WHERE id = ?`).run(id);
return result.changes > 0;
}
async find<T extends Record<string, unknown>>(
collection: string,
filter?: Record<string, unknown>,
opts?: { limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc' },
): Promise<T[]> {
const { clause, params } = buildFilterClause(filter);
let query = `SELECT * FROM ${collection}${clause}`;
if (opts?.orderBy) {
const dir = opts.order === 'desc' ? 'DESC' : 'ASC';
const col =
opts.orderBy === 'id' || opts.orderBy === 'created_at' || opts.orderBy === 'updated_at'
? opts.orderBy
: `json_extract(data_json, '$.${opts.orderBy}')`;
query += ` ORDER BY ${col} ${dir}`;
}
if (opts?.limit) {
query += ` LIMIT ?`;
params.push(opts.limit);
}
if (opts?.offset) {
query += ` OFFSET ?`;
params.push(opts.offset);
}
const rows = this.db.prepare(query).all(...params) as any[];
return rows.map((row) => ({ id: row.id, ...JSON.parse(row.data_json as string) }) as T);
}
async findOne<T extends Record<string, unknown>>(
collection: string,
filter: Record<string, unknown>,
): Promise<T | null> {
const results = await this.find<T>(collection, filter, { limit: 1 });
return results[0] ?? null;
}
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
const { clause, params } = buildFilterClause(filter);
const row = this.db
.prepare(`SELECT COUNT(*) as count FROM ${collection}${clause}`)
.get(...params) as any;
return row?.count ?? 0;
}
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
const txAdapter = new SqliteTxAdapter(this.db);
this.db.exec('BEGIN');
try {
const result = await fn(txAdapter);
this.db.exec('COMMIT');
return result;
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
}
async migrate(): Promise<void> {
const createTable = (name: string) =>
this.db.exec(`
CREATE TABLE IF NOT EXISTS ${name} (
id TEXT PRIMARY KEY,
data_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
for (const collection of COLLECTIONS) {
createTable(collection);
}
}
async close(): Promise<void> {
this.db.close();
}
}
/**
* Transaction wrapper that uses the same db handle — better-sqlite3 transactions
* are connection-level, so all statements on the same Database instance within
* a db.transaction() callback participate in the transaction.
*/
class SqliteTxAdapter implements StorageAdapter {
readonly name = 'sqlite';
private db: Database.Database;
constructor(db: Database.Database) {
this.db = db;
}
async create<T extends Record<string, unknown>>(
collection: string,
data: T,
): Promise<T & { id: string }> {
const id = (data as any).id ?? randomUUID();
const now = new Date().toISOString();
const rest = Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id'));
this.db
.prepare(
`INSERT INTO ${collection} (id, data_json, created_at, updated_at) VALUES (?, ?, ?, ?)`,
)
.run(id, JSON.stringify(rest), now, now);
return { ...data, id } as T & { id: string };
}
async read<T extends Record<string, unknown>>(collection: string, id: string): Promise<T | null> {
const row = this.db.prepare(`SELECT * FROM ${collection} WHERE id = ?`).get(id) as any;
if (!row) return null;
return { id: row.id, ...JSON.parse(row.data_json as string) } as T;
}
async update(collection: string, id: string, data: Record<string, unknown>): Promise<boolean> {
const existing = this.db
.prepare(`SELECT data_json FROM ${collection} WHERE id = ?`)
.get(id) as any;
if (!existing) return false;
const merged = { ...JSON.parse(existing.data_json as string), ...data };
const now = new Date().toISOString();
const result = this.db
.prepare(`UPDATE ${collection} SET data_json = ?, updated_at = ? WHERE id = ?`)
.run(JSON.stringify(merged), now, id);
return result.changes > 0;
}
async delete(collection: string, id: string): Promise<boolean> {
const result = this.db.prepare(`DELETE FROM ${collection} WHERE id = ?`).run(id);
return result.changes > 0;
}
async find<T extends Record<string, unknown>>(
collection: string,
filter?: Record<string, unknown>,
opts?: { limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc' },
): Promise<T[]> {
const { clause, params } = buildFilterClause(filter);
let query = `SELECT * FROM ${collection}${clause}`;
if (opts?.orderBy) {
const dir = opts.order === 'desc' ? 'DESC' : 'ASC';
const col =
opts.orderBy === 'id' || opts.orderBy === 'created_at' || opts.orderBy === 'updated_at'
? opts.orderBy
: `json_extract(data_json, '$.${opts.orderBy}')`;
query += ` ORDER BY ${col} ${dir}`;
}
if (opts?.limit) {
query += ` LIMIT ?`;
params.push(opts.limit);
}
if (opts?.offset) {
query += ` OFFSET ?`;
params.push(opts.offset);
}
const rows = this.db.prepare(query).all(...params) as any[];
return rows.map((row) => ({ id: row.id, ...JSON.parse(row.data_json as string) }) as T);
}
async findOne<T extends Record<string, unknown>>(
collection: string,
filter: Record<string, unknown>,
): Promise<T | null> {
const results = await this.find<T>(collection, filter, { limit: 1 });
return results[0] ?? null;
}
async count(collection: string, filter?: Record<string, unknown>): Promise<number> {
const { clause, params } = buildFilterClause(filter);
const row = this.db
.prepare(`SELECT COUNT(*) as count FROM ${collection}${clause}`)
.get(...params) as any;
return row?.count ?? 0;
}
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
return fn(this);
}
async migrate(): Promise<void> {
// No-op inside transaction
}
async close(): Promise<void> {
// No-op inside transaction
}
}

View File

@@ -1,17 +1,17 @@
export type { StorageAdapter, StorageConfig } from './types.js'; export type { StorageAdapter, StorageConfig } from './types.js';
export { createStorageAdapter, registerStorageAdapter } from './factory.js'; export { createStorageAdapter, registerStorageAdapter } from './factory.js';
export { PostgresAdapter } from './adapters/postgres.js'; export { PostgresAdapter } from './adapters/postgres.js';
export { SqliteAdapter } from './adapters/sqlite.js'; export { PgliteAdapter } from './adapters/pglite.js';
import { registerStorageAdapter } from './factory.js'; import { registerStorageAdapter } from './factory.js';
import { PostgresAdapter } from './adapters/postgres.js'; import { PostgresAdapter } from './adapters/postgres.js';
import { SqliteAdapter } from './adapters/sqlite.js'; import { PgliteAdapter } from './adapters/pglite.js';
import type { StorageConfig } from './types.js'; import type { StorageConfig } from './types.js';
registerStorageAdapter('postgres', (config: StorageConfig) => { registerStorageAdapter('postgres', (config: StorageConfig) => {
return new PostgresAdapter(config as Extract<StorageConfig, { type: 'postgres' }>); return new PostgresAdapter(config as Extract<StorageConfig, { type: 'postgres' }>);
}); });
registerStorageAdapter('sqlite', (config: StorageConfig) => { registerStorageAdapter('pglite', (config: StorageConfig) => {
return new SqliteAdapter(config as Extract<StorageConfig, { type: 'sqlite' }>); return new PgliteAdapter(config as Extract<StorageConfig, { type: 'pglite' }>);
}); });

View File

@@ -39,5 +39,5 @@ export interface StorageAdapter {
export type StorageConfig = export type StorageConfig =
| { type: 'postgres'; url: string } | { type: 'postgres'; url: string }
| { type: 'sqlite'; path: string } | { type: 'pglite'; dataDir?: string }
| { type: 'files'; dataDir: string; format?: 'json' | 'md' }; | { type: 'files'; dataDir: string; format?: 'json' | 'md' };

1793
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ packages:
- 'apps/*' - 'apps/*'
- 'packages/*' - 'packages/*'
- 'plugins/*' - 'plugins/*'
- '!packages/cli' # merged into @mosaic/mosaic
ignoredBuiltDependencies: ignoredBuiltDependencies:
- '@nestjs/core' - '@nestjs/core'

View File

@@ -3,7 +3,7 @@
# #
# Installs both components: # Installs both components:
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools) # 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
# 2. @mosaic/cli (npm) → ~/.npm-global/ (TUI, gateway client, wizard) # 2. @mosaic/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
# #
# Remote install (recommended): # Remote install (recommended):
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh) # bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
@@ -53,7 +53,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}" REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaic}" SCOPE="${MOSAIC_SCOPE:-@mosaic}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}" PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/cli" CLI_PKG="${SCOPE}/mosaic"
REPO_BASE="https://git.mosaicstack.dev/mosaic/mosaic-stack" REPO_BASE="https://git.mosaicstack.dev/mosaic/mosaic-stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
@@ -208,7 +208,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
fi fi
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# PART 2: @mosaic/cli (npm — TUI, gateway client, wizard) # PART 2: @mosaic/mosaic (npm — TUI, gateway client, wizard, CLI)
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_CLI" == "true" ]]; then if [[ "$FLAG_CLI" == "true" ]]; then