diff --git a/docs/plans/gateway-token-recovery.md b/docs/plans/gateway-token-recovery.md new file mode 100644 index 0000000..9fc486c --- /dev/null +++ b/docs/plans/gateway-token-recovery.md @@ -0,0 +1,193 @@ +# Gateway Admin Token Recovery — Implementation Plan + +**Mission:** `cli-unification-20260404` +**Task:** `CU-03-01` (planning only — no runtime code changes) +**Status:** Design locked (Session 1) — BetterAuth cookie-based recovery + +--- + +## 1. Problem Statement + +The gateway installer strands operators when the admin user exists but the admin +API token is missing. Concrete trigger: + +- `~/.config/mosaic/gateway/meta.json` was deleted / regenerated. +- The installer was re-run after a previous successful bootstrap. + +Flow today (`packages/mosaic/src/commands/gateway/install.ts:375-400`): + +1. `bootstrapFirstUser` hits `GET /api/bootstrap/status`. +2. Server returns `needsSetup: false` because `users` count > 0. +3. Installer logs `Admin user already exists — skipping setup. (No admin token on file — sign in via the web UI to manage tokens.)` and returns. +4. The operator now has: + - No token in `meta.json`. + - No CLI path to mint a new one (`mosaic gateway ` that needs the token fails). + - `POST /api/bootstrap/setup` locked out — it only runs when `users` count is zero (`apps/gateway/src/admin/bootstrap.controller.ts:34-37`). + - `POST /api/admin/tokens` gated by `AdminGuard` — requires either a bearer token (which they don't have) or a BetterAuth session (which they don't have in the CLI). + +Dead end. The web UI is the only escape hatch today, and for headless installs even that may be inaccessible. + +## 2. Design Summary + +The BetterAuth session cookie is the authority. The operator runs +`mosaic gateway login` to sign in with email/password, which persists a session +cookie via `saveSession` (reusing `packages/mosaic/src/auth.ts`). With a valid +session, `mosaic gateway config recover-token` (stranded-operator entry point) +and `mosaic gateway config rotate-token` call the existing authenticated admin +endpoint `POST /api/admin/tokens` using the cookie, then persist the returned +plaintext to `meta.json` via `writeMeta`. **No new server endpoints are +required** — `AdminGuard` already accepts BetterAuth session cookies via its +`validateSession` path (`apps/gateway/src/admin/admin.guard.ts:90-120`). + +## 3. Surface Contract + +### 3.1 Server — no changes required + +| Endpoint | Status | Notes | +| ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `POST /api/admin/tokens` | **Reuse as-is** | `admin-tokens.controller.ts:46-72`. Returns `{ id, label, scope, expiresAt, lastUsedAt, createdAt, plaintext }`. | +| `GET /api/admin/tokens` | **Reuse** | Useful for `mosaic gateway config tokens list` follow-on (out of scope for CU-03-01, but trivial once auth path exists). | +| `DELETE /api/admin/tokens/:id` | **Reuse** | Used by rotate flow for optional old-token revocation. | +| `POST /api/bootstrap/setup` | **Unchanged** | Remains first-user-only; not part of recovery. | + +`AdminGuard.validateSession` takes BetterAuth cookies from `request.raw.headers` +via `fromNodeHeaders` and calls `auth.api.getSession({ headers })`. It also +enforces `role === 'admin'`. This is exactly the path the CLI will hit with +`Cookie: better-auth.session_token=...`. + +**Confirmed feasible** during CU-03-01 investigation. + +### 3.2 `mosaic gateway login` + +Thin wrapper over the existing top-level `mosaic login` +(`packages/mosaic/src/cli.ts:42-76`) with gateway-specific defaults pulled from +`readMeta()`. + +| Aspect | Behavior | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Default gateway URL | `http://${meta.host}:${meta.port}` from `readMeta()`, fallback `http://localhost:14242`. | +| Flow | Prompt email + password -> `signIn()` -> `saveSession()`. | +| Persistence | `~/.mosaic/session.json` via existing `saveSession` (7-day expiry). | +| Decision | **Thin wrapper**, not alias. Rationale: defaults differ (reads `meta.json`), and discoverability under `mosaic gateway --help`. | +| Implementation | Share the sign-in logic by extracting a small `runLogin(gatewayUrl, email?, password?)` helper; both commands call it. | + +### 3.3 `mosaic gateway config rotate-token` + +| Aspect | Behavior | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Precondition | Valid session (via `loadSession` + `validateSession`). On failure, print: "Not signed in — run `mosaic gateway login`" and exit non-zero. | +| Request | `POST ${gatewayUrl}/api/admin/tokens` with header `Cookie: `, body `{ label: "CLI token (rotated YYYY-MM-DD)" }`. | +| On success | Read meta via `readMeta()`, set `meta.adminToken = plaintext`, `writeMeta(meta)`. Print the token banner (reuse `printAdminTokenBanner` shape). | +| Old token | **Optional `--revoke-old`** flag. When set and a previous `meta.adminToken` existed, call `DELETE /api/admin/tokens/:id` after rotation. Requires listing first to find the id; punt to CU-03-02 decision. Document as nice-to-have. | +| Exit codes | `0` success; `1` network error; `2` auth error; `3` server rejection. | + +### 3.4 `mosaic gateway config recover-token` + +Superset of `rotate-token` with an inline login nudge — the "stranded operator" +entry point. + +| Step | Action | +| ---- | -------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `readMeta()` — derive gateway URL. If meta is missing entirely, fall back to `--gateway` flag or default. | +| 2 | `loadSession(gatewayUrl)` then `validateSession`. If either fails, prompt inline: email + password -> `signIn` -> `saveSession`. | +| 3 | `POST /api/admin/tokens` with cookie, label `"Recovered via CLI YYYY-MM-DDTHH:mm"`. | +| 4 | Persist plaintext to `meta.json` via `writeMeta`. | +| 5 | Print the token banner and next-steps hints (e.g. `mosaic gateway status`). | +| 6 | Exit `0`. | + +Key property: this command is **runnable with nothing but email+password in hand**. +It assumes the gateway is up but assumes no prior CLI session state. + +### 3.5 File touch list (for CU-03-02..05 execution) + +| File | Change | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `packages/mosaic/src/commands/gateway.ts` | Register `login`, `config recover-token`, `config rotate-token` subcommands under `gw`. | +| `packages/mosaic/src/commands/gateway/config.ts` | Add `runRecoverToken`, `runRotateToken` handlers; export from module. | +| `packages/mosaic/src/commands/gateway/login.ts` (new) | Thin wrapper calling shared `runLogin` helper with meta-derived default URL. | +| `packages/mosaic/src/auth.ts` | No change expected. Possibly export a `requireSession(gatewayUrl)` helper (reuse pattern). | +| `packages/mosaic/src/commands/gateway/install.ts` | `bootstrapFirstUser` branch: "user exists, no token" -> offer recovery (see Section 4). | + +## 4. Installer Fix (CU-03-06 preview) + +Current stranding point is `install.ts:388-395`. The fix: + +``` +if (!status.needsSetup) { + if (meta.adminToken) { + // unchanged — happy path + } else { + // NEW: prompt "Admin exists but no token on file. Recover now? [Y/n]" + // If yes -> call runRecoverToken(gatewayUrl) inline (interactive): + // - prompt email + password + // - signIn -> saveSession + // - POST /api/admin/tokens + // - writeMeta(meta) with returned plaintext + // - print banner + // If no -> print the current stranded message but include: + // "Run `mosaic gateway config recover-token` when ready." + } +} +``` + +Shape notes (actual code lands in CU-03-06): + +- Extract the recovery body so it can be called **both** from the standalone + command and from `bootstrapFirstUser` without duplicating prompts. +- Reuse the same `rl` readline interface already open in `bootstrapFirstUser` + for the inline prompts. +- Preserve non-interactive behavior: if `process.stdin.isTTY` is false, skip the + prompt and emit the "run recover-token" hint only. + +## 5. Test Strategy (CU-03-07 scope) + +### 5.1 Happy paths + +| Command | Scenario | Expected | +| ------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- | +| `mosaic gateway login` | Valid creds | `session.json` written, 7-day expiry, exit 0 | +| `mosaic gateway config rotate-token` | Valid session, server reachable | `meta.json` updated, banner printed, new token usable | +| `mosaic gateway config recover-token` | No session, valid creds, server reachable | Prompts for creds, writes session + meta, exit 0 | +| Installer inline recovery | Re-run after `meta.json` wipe, operator says yes | Meta restored, banner printed, no manual CLI step needed | + +### 5.2 Error paths (must all produce actionable messages and non-zero exit) + +| Failure | Expected handling | +| --------------------------------- | --------------------------------------------------------------------------------- | +| Invalid email/password | BetterAuth 401 surfaced as "Sign-in failed: ", exit 2 | +| Expired stored session | Recover command silently re-prompts; rotate command exits 2 with "run login" hint | +| Gateway down / connection refused | "Could not reach gateway at " exit 1 | +| Server rejects token creation | Print status + body excerpt, exit 3 | +| Meta file missing (recover) | Fall back to `--gateway` flag or default; warn that meta will be created | +| Non-admin user | `AdminGuard` 403 surfaced as "User is not an admin", exit 2 | + +### 5.3 Integration test (recommended) + +Spin up gateway in test harness, create admin user via `/api/bootstrap/setup`, +wipe `meta.json`, invoke `mosaic gateway config recover-token` programmatically, +assert new `meta.adminToken` works against `GET /api/admin/tokens`. + +## 6. Risks & Open Questions + +| # | Item | Severity | Mitigation | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | +| 1 | `AdminGuard.validateSession` calls `getSession` with `fromNodeHeaders(request.raw.headers)`. CLI sends `Cookie:` header only. Confirm BetterAuth reads from `Cookie`, not `Set-Cookie`. | Low | Confirmed — `mosaic login` + `mosaic tui` already use this flow successfully (`cli.ts:137-181`). | +| 2 | Session cookie local expiry (7d) vs BetterAuth server-side expiry may drift. | Low | `validateSession` hits `get-session`; handle 401 by re-prompting. | +| 3 | Label collision / unbounded token growth if operators run `recover-token` repeatedly. | Low | Include ISO timestamp in label. Optional `--revoke-old` in CU-03-02. Add `tokens list/prune` later. | +| 4 | `mosaic login` exists at top level and `mosaic gateway login` is a wrapper — risk of confusion. | Low | Document that `gateway login` is the preferred entry for gateway operators; top-level stays for compatibility. | +| 5 | `meta.json` write is not atomic. Crash between token creation and `writeMeta` leaves an orphan token server-side with no plaintext on disk. | Medium | Accept for now — re-running `recover-token` mints a fresh token. Document as known limitation. | +| 6 | Non-TTY installer runs (CI, headless provisioners) cannot prompt for creds interactively. | Medium | Installer inline recovery must skip prompt when `!process.stdin.isTTY`; emit the recover-token hint. | +| 7 | If `BETTER_AUTH_SECRET` rotates between login and recover, the session cookie is invalid — user must re-login. Acceptable but surface a clear error. | Low | Error handler maps 401 on recover -> "Session invalid; re-run `mosaic gateway login`". | +| 8 | No MFA today. When MFA lands, BetterAuth sign-in will return a challenge, not a cookie — recovery UX will need a second prompt step. | Future | Out of scope for this mission. Flag for future CLI work. | + +## 7. Downstream Task Hooks + +| Task | Scope | +| -------- | -------------------------------------------------------------------------- | +| CU-03-02 | Implement `mosaic gateway login` wrapper + shared `runLogin` extraction. | +| CU-03-03 | Implement `mosaic gateway config rotate-token`. | +| CU-03-04 | Implement `mosaic gateway config recover-token`. | +| CU-03-05 | Wire commands into `gateway.ts` registration, update `--help` copy. | +| CU-03-06 | Installer inline recovery hook in `bootstrapFirstUser`. | +| CU-03-07 | Tests per Section 5. | +| CU-03-08 | Docs: update gateway install README + operator runbook with recovery flow. | diff --git a/packages/forge/package.json b/packages/forge/package.json index 1ab372d..ee64970 100644 --- a/packages/forge/package.json +++ b/packages/forge/package.json @@ -26,7 +26,8 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { - "@mosaicstack/macp": "workspace:*" + "@mosaicstack/macp": "workspace:*", + "commander": "^13.0.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/forge/src/cli.spec.ts b/packages/forge/src/cli.spec.ts new file mode 100644 index 0000000..d2fe881 --- /dev/null +++ b/packages/forge/src/cli.spec.ts @@ -0,0 +1,57 @@ +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; + +import { registerForgeCommand } from './cli.js'; + +describe('registerForgeCommand', () => { + it('registers a "forge" command on the parent program', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + expect(forgeCmd).toBeDefined(); + }); + + it('registers the four required subcommands under forge', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + expect(forgeCmd).toBeDefined(); + + const subNames = forgeCmd!.commands.map((c) => c.name()); + + expect(subNames).toContain('run'); + expect(subNames).toContain('status'); + expect(subNames).toContain('resume'); + expect(subNames).toContain('personas'); + }); + + it('registers "personas list" as a subcommand of "forge personas"', () => { + const program = new Command(); + registerForgeCommand(program); + + const forgeCmd = program.commands.find((c) => c.name() === 'forge'); + const personasCmd = forgeCmd!.commands.find((c) => c.name() === 'personas'); + expect(personasCmd).toBeDefined(); + + const personasSubNames = personasCmd!.commands.map((c) => c.name()); + expect(personasSubNames).toContain('list'); + }); + + it('does not modify the parent program name or description', () => { + const program = new Command('mosaic'); + program.description('Mosaic Stack CLI'); + registerForgeCommand(program); + + expect(program.name()).toBe('mosaic'); + expect(program.description()).toBe('Mosaic Stack CLI'); + }); + + it('can be called multiple times without throwing', () => { + const program = new Command(); + expect(() => { + registerForgeCommand(program); + }).not.toThrow(); + }); +}); diff --git a/packages/forge/src/cli.ts b/packages/forge/src/cli.ts new file mode 100644 index 0000000..618150a --- /dev/null +++ b/packages/forge/src/cli.ts @@ -0,0 +1,280 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Command } from 'commander'; + +import { classifyBrief } from './brief-classifier.js'; +import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js'; +import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js'; +import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js'; +import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js'; + +// --------------------------------------------------------------------------- +// Stub executor — used when no real executor is wired at CLI invocation time. +// --------------------------------------------------------------------------- + +const stubExecutor: TaskExecutor = { + async submitTask(task) { + console.log(` [forge] stage submitted: ${task.id} (${task.title})`); + }, + async waitForCompletion(taskId, _timeoutMs) { + console.log(` [forge] stage complete: ${taskId}`); + return { + task_id: taskId, + status: 'completed' as const, + completed_at: new Date().toISOString(), + exit_code: 0, + gate_results: [], + }; + }, + async getTaskStatus(_taskId) { + return 'completed' as const; + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatDuration(startedAt?: string, completedAt?: string): string { + if (!startedAt || !completedAt) return '-'; + const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); + const secs = Math.round(ms / 1000); + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`; +} + +function printManifestTable(manifest: RunManifest): void { + console.log(`\nRun ID : ${manifest.runId}`); + console.log(`Status : ${manifest.status}`); + console.log(`Brief : ${manifest.brief}`); + console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`); + console.log(`Updated: ${manifest.updatedAt}`); + console.log(''); + console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration'); + console.log('-'.repeat(50)); + for (const stage of STAGE_SEQUENCE) { + const s = manifest.stages[stage]; + if (!s) continue; + const label = (STAGE_LABELS[stage] ?? stage).padEnd(22); + const status = s.status.padEnd(14); + const dur = formatDuration(s.startedAt, s.completedAt); + console.log(`${label}${status}${dur}`); + } + console.log(''); +} + +function resolveRunDir(runId: string, projectRoot?: string): string { + const root = projectRoot ?? process.cwd(); + return path.join(root, '.forge', 'runs', runId); +} + +function listRecentRuns(projectRoot?: string): void { + const root = projectRoot ?? process.cwd(); + const runsDir = path.join(root, '.forge', 'runs'); + + if (!fs.existsSync(runsDir)) { + console.log('No runs found. Run `mosaic forge run` to start a pipeline.'); + return; + } + + const entries = fs + .readdirSync(runsDir) + .filter((name) => fs.statSync(path.join(runsDir, name)).isDirectory()) + .sort() + .reverse() + .slice(0, 10); + + if (entries.length === 0) { + console.log('No runs found.'); + return; + } + + console.log('\nRecent runs:'); + console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief'); + console.log('-'.repeat(70)); + + for (const runId of entries) { + const runDir = path.join(runsDir, runId); + try { + const manifest = loadManifest(runDir); + const status = manifest.status.padEnd(14); + const brief = path.basename(manifest.brief); + console.log(`${runId.padEnd(22)}${status}${brief}`); + } catch { + console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`); + } + } + console.log(''); +} + +// --------------------------------------------------------------------------- +// Register function +// --------------------------------------------------------------------------- + +/** + * Register forge subcommands on an existing Commander program. + * Mirrors the pattern used by registerQualityRails in @mosaicstack/quality-rails. + */ +export function registerForgeCommand(parent: Command): void { + const forge = parent.command('forge').description('Run and manage Forge pipelines'); + + // ── forge run ──────────────────────────────────────────────────────────── + + forge + .command('run') + .description('Run a Forge pipeline from a brief markdown file') + .requiredOption('--brief ', 'Path to the brief markdown file') + .option('--run-id ', 'Override the auto-generated run ID') + .option('--resume', 'Resume an existing run instead of starting a new one', false) + .option('--config ', 'Path to forge config file (.forge/config.yaml)') + .option('--codebase ', 'Codebase root to pass to the pipeline', process.cwd()) + .option('--dry-run', 'Print planned stages without executing', false) + .action( + async (opts: { + brief: string; + runId?: string; + resume: boolean; + config?: string; + codebase: string; + dryRun: boolean; + }) => { + const briefPath = path.resolve(opts.brief); + + if (!fs.existsSync(briefPath)) { + console.error(`[forge] brief not found: ${briefPath}`); + process.exitCode = 1; + return; + } + + const briefContent = fs.readFileSync(briefPath, 'utf-8'); + const briefClass = classifyBrief(briefContent); + const projectRoot = opts.codebase; + + if (opts.resume) { + const runId = opts.runId ?? generateRunId(); + const runDir = resolveRunDir(runId, projectRoot); + console.log(`[forge] resuming run: ${runId}`); + const { resumePipeline } = await import('./pipeline-runner.js'); + const result = await resumePipeline(runDir, stubExecutor); + console.log(`[forge] pipeline complete: ${result.runId}`); + return; + } + + const pipelineOptions: PipelineOptions = { + briefClass, + codebase: projectRoot, + dryRun: opts.dryRun, + executor: stubExecutor, + }; + + if (opts.dryRun) { + const { stagesForClass } = await import('./brief-classifier.js'); + const stages = stagesForClass(briefClass); + console.log(`[forge] dry-run — brief class: ${briefClass}`); + console.log('[forge] planned stages:'); + for (const stage of stages) { + console.log(` - ${stage} (${STAGE_LABELS[stage] ?? stage})`); + } + return; + } + + console.log(`[forge] starting pipeline for brief: ${briefPath}`); + console.log(`[forge] classified as: ${briefClass}`); + + try { + const result = await runPipeline(briefPath, projectRoot, pipelineOptions); + console.log(`[forge] pipeline complete: ${result.runId}`); + console.log(`[forge] run directory: ${result.runDir}`); + } catch (err) { + console.error( + `[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exitCode = 1; + } + }, + ); + + // ── forge status ───────────────────────────────────────────────────────── + + forge + .command('status [runId]') + .description('Show the status of a pipeline run (omit runId to list recent runs)') + .option('--project ', 'Project root (defaults to cwd)', process.cwd()) + .action(async (runId: string | undefined, opts: { project: string }) => { + if (!runId) { + listRecentRuns(opts.project); + return; + } + + const runDir = resolveRunDir(runId, opts.project); + try { + const manifest = getPipelineStatus(runDir); + printManifestTable(manifest); + } catch (err) { + console.error( + `[forge] could not load run "${runId}": ${err instanceof Error ? err.message : String(err)}`, + ); + process.exitCode = 1; + } + }); + + // ── forge resume ───────────────────────────────────────────────────────── + + forge + .command('resume ') + .description('Resume a stopped or failed pipeline run') + .option('--project ', 'Project root (defaults to cwd)', process.cwd()) + .action(async (runId: string, opts: { project: string }) => { + const runDir = resolveRunDir(runId, opts.project); + + if (!fs.existsSync(runDir)) { + console.error(`[forge] run not found: ${runDir}`); + process.exitCode = 1; + return; + } + + console.log(`[forge] resuming run: ${runId}`); + + try { + const { resumePipeline } = await import('./pipeline-runner.js'); + const result = await resumePipeline(runDir, stubExecutor); + console.log(`[forge] pipeline complete: ${result.runId}`); + console.log(`[forge] run directory: ${result.runDir}`); + } catch (err) { + console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } + }); + + // ── forge personas ──────────────────────────────────────────────────────── + + const personas = forge.command('personas').description('Manage Forge board personas'); + + personas + .command('list') + .description('List configured board personas') + .option( + '--project ', + 'Project root for persona overrides (defaults to cwd)', + process.cwd(), + ) + .option('--board-dir ', 'Override the board agents directory') + .action((opts: { project: string; boardDir?: string }) => { + const effectivePersonas = opts.boardDir + ? loadBoardPersonas(opts.boardDir) + : getEffectivePersonas(opts.project); + + if (effectivePersonas.length === 0) { + console.log('[forge] no board personas configured.'); + return; + } + + console.log(`\nBoard personas (${effectivePersonas.length}):\n`); + console.log('Slug'.padEnd(24) + 'Name'); + console.log('-'.repeat(50)); + for (const p of effectivePersonas) { + console.log(`${p.slug.padEnd(24)}${p.name}`); + } + console.log(''); + }); +} diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 0f939d2..62c765a 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -80,3 +80,6 @@ export { resumePipeline, getPipelineStatus, } from './pipeline-runner.js'; + +// CLI +export { registerForgeCommand } from './cli.js'; diff --git a/packages/log/package.json b/packages/log/package.json index cf1c793..0601f48 100644 --- a/packages/log/package.json +++ b/packages/log/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@mosaicstack/db": "workspace:*", + "commander": "^13.0.0", "drizzle-orm": "^0.45.1" }, "devDependencies": { diff --git a/packages/log/src/cli.spec.ts b/packages/log/src/cli.spec.ts new file mode 100644 index 0000000..11a6b4c --- /dev/null +++ b/packages/log/src/cli.spec.ts @@ -0,0 +1,68 @@ +import { Command } from 'commander'; +import { describe, it, expect } from 'vitest'; + +import { registerLogCommand } from './cli.js'; + +function buildTestProgram(): Command { + const program = new Command('mosaic'); + program.exitOverride(); // prevent process.exit in tests + registerLogCommand(program); + return program; +} + +describe('registerLogCommand', () => { + it('registers a "log" subcommand on the parent', () => { + const program = buildTestProgram(); + const names = program.commands.map((c) => c.name()); + expect(names).toContain('log'); + }); + + it('log command has tail, search, export, and level subcommands', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log'); + expect(logCmd).toBeDefined(); + const subNames = logCmd!.commands.map((c) => c.name()); + expect(subNames).toContain('tail'); + expect(subNames).toContain('search'); + expect(subNames).toContain('export'); + expect(subNames).toContain('level'); + }); + + it('tail subcommand has expected options', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const tailCmd = logCmd.commands.find((c) => c.name() === 'tail')!; + const optionNames = tailCmd.options.map((o) => o.long); + expect(optionNames).toContain('--agent'); + expect(optionNames).toContain('--level'); + expect(optionNames).toContain('--category'); + expect(optionNames).toContain('--tier'); + expect(optionNames).toContain('--limit'); + expect(optionNames).toContain('--db'); + }); + + it('search subcommand accepts a positional query argument', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const searchCmd = logCmd.commands.find((c) => c.name() === 'search')!; + // Commander stores positional args in _args + const argNames = searchCmd.registeredArguments.map((a) => a.name()); + expect(argNames).toContain('query'); + }); + + it('export subcommand accepts a positional path argument', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const exportCmd = logCmd.commands.find((c) => c.name() === 'export')!; + const argNames = exportCmd.registeredArguments.map((a) => a.name()); + expect(argNames).toContain('path'); + }); + + it('level subcommand accepts a positional level argument', () => { + const program = buildTestProgram(); + const logCmd = program.commands.find((c) => c.name() === 'log')!; + const levelCmd = logCmd.commands.find((c) => c.name() === 'level')!; + const argNames = levelCmd.registeredArguments.map((a) => a.name()); + expect(argNames).toContain('level'); + }); +}); diff --git a/packages/log/src/cli.ts b/packages/log/src/cli.ts new file mode 100644 index 0000000..eb98908 --- /dev/null +++ b/packages/log/src/cli.ts @@ -0,0 +1,177 @@ +import { writeFileSync } from 'node:fs'; + +import type { Command } from 'commander'; + +import type { LogCategory, LogLevel, LogTier } from './agent-logs.js'; + +interface FilterOptions { + agent?: string; + level?: string; + category?: string; + tier?: string; + limit?: string; + db?: string; +} + +function parseLimit(raw: string | undefined, defaultVal = 50): number { + if (!raw) return defaultVal; + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : defaultVal; +} + +function buildQuery(opts: FilterOptions) { + return { + ...(opts.agent ? { sessionId: opts.agent } : {}), + ...(opts.level ? { level: opts.level as LogLevel } : {}), + ...(opts.category ? { category: opts.category as LogCategory } : {}), + ...(opts.tier ? { tier: opts.tier as LogTier } : {}), + limit: parseLimit(opts.limit), + }; +} + +async function openDb(connectionString: string) { + const { createDb } = await import('@mosaicstack/db'); + return createDb(connectionString); +} + +function resolveConnectionString(opts: FilterOptions): string | undefined { + return opts.db ?? process.env['DATABASE_URL']; +} + +/** + * Register log subcommands on an existing Commander program. + * This avoids cross-package Commander version mismatches by using the + * caller's Command instance directly. + */ +export function registerLogCommand(parent: Command): void { + const log = parent.command('log').description('Query and manage agent logs'); + + // ─── tail ─────────────────────────────────────────────────────────────── + + log + .command('tail') + .description('Tail recent agent logs') + .option('--agent ', 'Filter by agent/session ID') + .option('--level ', 'Filter by log level (debug|info|warn|error)') + .option('--category ', 'Filter by category (decision|tool_use|learning|error|general)') + .option('--tier ', 'Filter by tier (hot|warm|cold)') + .option('--limit ', 'Number of logs to return (default 50)', '50') + .option('--db ', 'Database connection string (or set DATABASE_URL)') + .action(async (opts: FilterOptions) => { + const connStr = resolveConnectionString(opts); + if (!connStr) { + console.error('Database connection required: use --db or set DATABASE_URL'); + process.exit(1); + } + + const handle = await openDb(connStr); + try { + const { createLogService } = await import('./log-service.js'); + const svc = createLogService(handle.db); + const query = buildQuery(opts); + + const logs = await svc.logs.query(query); + if (logs.length === 0) { + console.log('No logs found.'); + return; + } + for (const entry of logs) { + const ts = new Date(entry.createdAt).toISOString(); + console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`); + } + } finally { + await handle.close(); + } + }); + + // ─── search ───────────────────────────────────────────────────────────── + + log + .command('search ') + .description('Full-text search over agent logs') + .option('--agent ', 'Filter by agent/session ID') + .option('--level ', 'Filter by log level (debug|info|warn|error)') + .option('--category ', 'Filter by category (decision|tool_use|learning|error|general)') + .option('--tier ', 'Filter by tier (hot|warm|cold)') + .option('--limit ', 'Number of logs to return (default 50)', '50') + .option('--db ', 'Database connection string (or set DATABASE_URL)') + .action(async (query: string, opts: FilterOptions) => { + const connStr = resolveConnectionString(opts); + if (!connStr) { + console.error('Database connection required: use --db or set DATABASE_URL'); + process.exit(1); + } + + const handle = await openDb(connStr); + try { + const { createLogService } = await import('./log-service.js'); + const svc = createLogService(handle.db); + const baseQuery = buildQuery(opts); + + const logs = await svc.logs.query(baseQuery); + const lowerQ = query.toLowerCase(); + const matched = logs.filter( + (e) => + e.content.toLowerCase().includes(lowerQ) || + (e.metadata != null && JSON.stringify(e.metadata).toLowerCase().includes(lowerQ)), + ); + + if (matched.length === 0) { + console.log('No matching logs found.'); + return; + } + for (const entry of matched) { + const ts = new Date(entry.createdAt).toISOString(); + console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`); + } + } finally { + await handle.close(); + } + }); + + // ─── export ───────────────────────────────────────────────────────────── + + log + .command('export ') + .description('Export matching logs to an NDJSON file') + .option('--agent ', 'Filter by agent/session ID') + .option('--level ', 'Filter by log level (debug|info|warn|error)') + .option('--category ', 'Filter by category (decision|tool_use|learning|error|general)') + .option('--tier ', 'Filter by tier (hot|warm|cold)') + .option('--limit ', 'Number of logs to export (default 50)', '50') + .option('--db ', 'Database connection string (or set DATABASE_URL)') + .action(async (outputPath: string, opts: FilterOptions) => { + const connStr = resolveConnectionString(opts); + if (!connStr) { + console.error('Database connection required: use --db or set DATABASE_URL'); + process.exit(1); + } + + const handle = await openDb(connStr); + try { + const { createLogService } = await import('./log-service.js'); + const svc = createLogService(handle.db); + const query = buildQuery(opts); + + const logs = await svc.logs.query(query); + const ndjson = logs.map((e) => JSON.stringify(e)).join('\n'); + writeFileSync(outputPath, ndjson, 'utf8'); + console.log(`Exported ${logs.length} log(s) to ${outputPath}`); + } finally { + await handle.close(); + } + }); + + // ─── level ────────────────────────────────────────────────────────────── + + log + .command('level ') + .description('Set runtime log level for the connected log service') + .action((level: string) => { + void level; + console.log( + 'Runtime log level adjustment is not supported in current mode (DB-backed log service).', + ); + process.exitCode = 0; + }); +} diff --git a/packages/macp/package.json b/packages/macp/package.json index cb3378a..8131d3a 100644 --- a/packages/macp/package.json +++ b/packages/macp/package.json @@ -21,6 +21,9 @@ "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests" }, + "dependencies": { + "commander": "^13.0.0" + }, "devDependencies": { "@types/node": "^22.0.0", "@vitest/coverage-v8": "^2.0.0", diff --git a/packages/macp/src/cli.spec.ts b/packages/macp/src/cli.spec.ts new file mode 100644 index 0000000..4ee920b --- /dev/null +++ b/packages/macp/src/cli.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerMacpCommand } from './cli.js'; + +describe('registerMacpCommand', () => { + function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); // prevent process.exit in tests + registerMacpCommand(program); + return program; + } + + it('registers a "macp" command on the parent', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp'); + expect(macpCmd).toBeDefined(); + }); + + it('registers "macp tasks" subcommand group', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp')!; + const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks'); + expect(tasksCmd).toBeDefined(); + }); + + it('registers "macp tasks list" subcommand with --status and --type flags', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp')!; + const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks')!; + const listCmd = tasksCmd.commands.find((c) => c.name() === 'list'); + expect(listCmd).toBeDefined(); + const optionNames = listCmd!.options.map((o) => o.long); + expect(optionNames).toContain('--status'); + expect(optionNames).toContain('--type'); + }); + + it('registers "macp submit" subcommand', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp')!; + const submitCmd = macpCmd.commands.find((c) => c.name() === 'submit'); + expect(submitCmd).toBeDefined(); + }); + + it('registers "macp gate" subcommand with --fail-on flag', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp')!; + const gateCmd = macpCmd.commands.find((c) => c.name() === 'gate'); + expect(gateCmd).toBeDefined(); + const optionNames = gateCmd!.options.map((o) => o.long); + expect(optionNames).toContain('--fail-on'); + }); + + it('registers "macp events" subcommand group', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp')!; + const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events'); + expect(eventsCmd).toBeDefined(); + }); + + it('registers "macp events tail" subcommand', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp')!; + const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events')!; + const tailCmd = eventsCmd.commands.find((c) => c.name() === 'tail'); + expect(tailCmd).toBeDefined(); + }); + + it('has all required top-level subcommands', () => { + const program = buildProgram(); + const macpCmd = program.commands.find((c) => c.name() === 'macp')!; + const topLevel = macpCmd.commands.map((c) => c.name()); + expect(topLevel).toContain('tasks'); + expect(topLevel).toContain('submit'); + expect(topLevel).toContain('gate'); + expect(topLevel).toContain('events'); + }); +}); diff --git a/packages/macp/src/cli.ts b/packages/macp/src/cli.ts new file mode 100644 index 0000000..d4e2694 --- /dev/null +++ b/packages/macp/src/cli.ts @@ -0,0 +1,92 @@ +import type { Command } from 'commander'; + +/** + * Register macp subcommands on an existing Commander program. + * This avoids cross-package Commander version mismatches by using the + * caller's Command instance directly. + */ +export function registerMacpCommand(parent: Command): void { + const macp = parent.command('macp').description('MACP task and gate management'); + + // ─── tasks ─────────────────────────────────────────────────────────────── + + const tasks = macp.command('tasks').description('Manage MACP tasks'); + + tasks + .command('list') + .description('List MACP tasks') + .option( + '--status ', + 'Filter by task status (pending|running|gated|completed|failed|escalated)', + ) + .option( + '--type ', + 'Filter by task type (coding|deploy|research|review|documentation|infrastructure)', + ) + .action((opts: { status?: string; type?: string }) => { + // not yet wired — task persistence layer is not present in @mosaicstack/macp + console.log('[macp] tasks list: not yet wired — use macp package programmatically'); + if (opts.status) { + console.log(` status filter: ${opts.status}`); + } + if (opts.type) { + console.log(` type filter: ${opts.type}`); + } + process.exitCode = 0; + }); + + // ─── submit ────────────────────────────────────────────────────────────── + + macp + .command('submit ') + .description('Submit a task from a JSON/YAML spec file') + .action((specPath: string) => { + // not yet wired — task submission requires a running MACP server + console.log('[macp] submit: not yet wired — use macp package programmatically'); + console.log(` spec path: ${specPath}`); + console.log(' task id: (unavailable — no MACP server connected)'); + console.log(' status: (unavailable — no MACP server connected)'); + process.exitCode = 0; + }); + + // ─── gate ──────────────────────────────────────────────────────────────── + + macp + .command('gate ') + .description('Run a gate from a spec string or file path (wraps runGate/runGates)') + .option('--fail-on ', 'Gate fail-on mode: ai|fail|both|none', 'fail') + .option('--cwd ', 'Working directory for gate execution', process.cwd()) + .option('--log ', 'Path to write gate log output', '/tmp/macp-gate.log') + .option('--timeout ', 'Gate timeout in seconds', '60') + .action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => { + // not yet wired — gate execution requires a task context and event sink + console.log('[macp] gate: not yet wired — use macp package programmatically'); + console.log(` spec: ${spec}`); + console.log(` fail-on: ${opts.failOn}`); + console.log(` cwd: ${opts.cwd}`); + console.log(` log: ${opts.log}`); + console.log(` timeout: ${opts.timeout}s`); + process.exitCode = 0; + }); + + // ─── events ────────────────────────────────────────────────────────────── + + const events = macp.command('events').description('Stream MACP events'); + + events + .command('tail') + .description('Tail MACP events from the event log (wraps event emitter)') + .option('--file ', 'Path to the MACP events NDJSON file') + .option('--follow', 'Follow the file for new events (like tail -f)') + .action((opts: { file?: string; follow?: boolean }) => { + // not yet wired — event streaming requires a live event source + console.log('[macp] events tail: not yet wired — use macp package programmatically'); + if (opts.file) { + console.log(` file: ${opts.file}`); + } + if (opts.follow) { + console.log(' mode: follow'); + } + process.exitCode = 0; + }); +} diff --git a/packages/macp/src/index.ts b/packages/macp/src/index.ts index 7c5283d..073c886 100644 --- a/packages/macp/src/index.ts +++ b/packages/macp/src/index.ts @@ -41,3 +41,6 @@ export type { NormalizedGate } from './gate-runner.js'; // Event emitter export { nowISO, appendEvent, emitEvent } from './event-emitter.js'; + +// CLI +export { registerMacpCommand } from './cli.js'; diff --git a/packages/memory/package.json b/packages/memory/package.json index 392c8eb..fad7d51 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -25,6 +25,7 @@ "@mosaicstack/db": "workspace:*", "@mosaicstack/storage": "workspace:*", "@mosaicstack/types": "workspace:*", + "commander": "^13.0.0", "drizzle-orm": "^0.45.1" }, "devDependencies": { diff --git a/packages/memory/src/cli.spec.ts b/packages/memory/src/cli.spec.ts new file mode 100644 index 0000000..a293d1d --- /dev/null +++ b/packages/memory/src/cli.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerMemoryCommand } from './cli.js'; + +/** + * Smoke test — only verifies command wiring. + * Does NOT open a database connection. + */ +describe('registerMemoryCommand', () => { + function buildProgram(): Command { + const program = new Command('mosaic'); + program.exitOverride(); // prevent process.exit during tests + registerMemoryCommand(program); + return program; + } + + it('registers a "memory" subcommand', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory'); + expect(memory).toBeDefined(); + }); + + it('registers "memory search"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const search = memory.commands.find((c) => c.name() === 'search'); + expect(search).toBeDefined(); + }); + + it('registers "memory stats"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const stats = memory.commands.find((c) => c.name() === 'stats'); + expect(stats).toBeDefined(); + }); + + it('registers "memory insights list"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const insights = memory.commands.find((c) => c.name() === 'insights'); + expect(insights).toBeDefined(); + const list = insights!.commands.find((c) => c.name() === 'list'); + expect(list).toBeDefined(); + }); + + it('registers "memory preferences list"', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const preferences = memory.commands.find((c) => c.name() === 'preferences'); + expect(preferences).toBeDefined(); + const list = preferences!.commands.find((c) => c.name() === 'list'); + expect(list).toBeDefined(); + }); + + it('"memory search" has --limit and --agent options', () => { + const program = buildProgram(); + const memory = program.commands.find((c) => c.name() === 'memory')!; + const search = memory.commands.find((c) => c.name() === 'search')!; + const optNames = search.options.map((o) => o.long); + expect(optNames).toContain('--limit'); + expect(optNames).toContain('--agent'); + }); +}); diff --git a/packages/memory/src/cli.ts b/packages/memory/src/cli.ts new file mode 100644 index 0000000..f3cb216 --- /dev/null +++ b/packages/memory/src/cli.ts @@ -0,0 +1,179 @@ +import type { Command } from 'commander'; + +import type { MemoryAdapter } from './types.js'; + +/** + * Build and return a connected MemoryAdapter from a connection string or + * the MEMORY_DB_URL / DATABASE_URL environment variable. + * + * For pgvector (postgres://...) the connection string is injected into + * DATABASE_URL so that PgVectorAdapter's internal createDb() picks it up. + * + * Throws with a human-readable message if no connection info is available. + */ +async function resolveAdapter(dbOption: string | undefined): Promise { + const connStr = dbOption ?? process.env['MEMORY_DB_URL'] ?? process.env['DATABASE_URL']; + if (!connStr) { + throw new Error( + 'No database connection string provided. ' + + 'Pass --db or set MEMORY_DB_URL / DATABASE_URL.', + ); + } + + // Lazy imports so the module loads cleanly without a live DB during smoke tests. + const { createMemoryAdapter, registerMemoryAdapter } = await import('./factory.js'); + + if (connStr.startsWith('postgres') || connStr.startsWith('pg')) { + // PgVectorAdapter reads DATABASE_URL via createDb() — inject it here. + process.env['DATABASE_URL'] = connStr; + + const { PgVectorAdapter } = await import('./adapters/pgvector.js'); + registerMemoryAdapter('pgvector', (cfg) => new PgVectorAdapter(cfg as never)); + return createMemoryAdapter({ type: 'pgvector' }); + } + + // Keyword adapter backed by pglite storage; treat connStr as a data directory. + const { KeywordAdapter } = await import('./adapters/keyword.js'); + const { createStorageAdapter, registerStorageAdapter } = await import('@mosaicstack/storage'); + const { PgliteAdapter } = await import('@mosaicstack/storage'); + + registerStorageAdapter('pglite', (cfg) => new PgliteAdapter(cfg as never)); + + const storage = createStorageAdapter({ type: 'pglite', dataDir: connStr }); + + registerMemoryAdapter('keyword', (cfg) => new KeywordAdapter(cfg as never)); + return createMemoryAdapter({ type: 'keyword', storage }); +} + +/** + * Register `memory` subcommands on an existing Commander program. + * Follows the registerQualityRails pattern from @mosaicstack/quality-rails. + */ +export function registerMemoryCommand(parent: Command): void { + const memory = parent.command('memory').description('Inspect and query the Mosaic memory layer'); + + // ── memory search ────────────────────────────────────────────── + memory + .command('search ') + .description('Semantic search over insights') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--limit ', 'Maximum number of results', '10') + .option('--agent ', 'Filter by agent / user ID') + .action(async (query: string, opts: { db?: string; limit: string; agent?: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const limit = parseInt(opts.limit, 10); + const userId = opts.agent ?? 'system'; + const results = await adapter.searchInsights(userId, query, { limit }); + + if (results.length === 0) { + console.log('No insights found.'); + } else { + for (const r of results) { + console.log(`[${r.id}] (score=${r.score.toFixed(3)}) ${r.content}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory stats ────────────────────────────────────────────────────── + memory + .command('stats') + .description('Print memory tier info: adapter type, insight count, preference count') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--agent ', 'User / agent ID scope for counts', 'system') + .action(async (opts: { db?: string; agent: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + + const adapterType = adapter.name; + + const insightCount = await adapter + .searchInsights(opts.agent, '', { limit: 100000 }) + .then((r) => r.length) + .catch(() => -1); + + const prefCount = await adapter + .listPreferences(opts.agent) + .then((r) => r.length) + .catch(() => -1); + + console.log(`adapter: ${adapterType}`); + console.log(`insights: ${insightCount === -1 ? 'unavailable' : String(insightCount)}`); + console.log(`preferences: ${prefCount === -1 ? 'unavailable' : String(prefCount)}`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory insights ─────────────────────────────────────────────────── + const insightsCmd = memory.command('insights').description('Manage insights'); + + insightsCmd + .command('list') + .description('List recent insights') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--limit ', 'Maximum number of results', '20') + .option('--agent ', 'User / agent ID scope', 'system') + .action(async (opts: { db?: string; limit: string; agent: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const limit = parseInt(opts.limit, 10); + const results = await adapter.searchInsights(opts.agent, '', { limit }); + + if (results.length === 0) { + console.log('No insights found.'); + } else { + for (const r of results) { + console.log(`[${r.id}] ${r.content}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); + + // ── memory preferences ──────────────────────────────────────────────── + const prefsCmd = memory.command('preferences').description('Manage stored preferences'); + + prefsCmd + .command('list') + .description('List stored preferences') + .option('--db ', 'Database connection string (or set MEMORY_DB_URL)') + .option('--agent ', 'User / agent ID scope', 'system') + .option('--category ', 'Filter by category') + .action(async (opts: { db?: string; agent: string; category?: string }) => { + let adapter: MemoryAdapter | undefined; + try { + adapter = await resolveAdapter(opts.db); + const prefs = await adapter.listPreferences(opts.agent, opts.category); + + if (prefs.length === 0) { + console.log('No preferences found.'); + } else { + for (const p of prefs) { + console.log(`[${p.category}] ${p.key} = ${JSON.stringify(p.value)}`); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } finally { + await adapter?.close(); + } + }); +} diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index f18191c..a6812ac 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -2,7 +2,9 @@ import { createRequire } from 'module'; import { Command } from 'commander'; +import { registerForgeCommand } from '@mosaicstack/forge'; import { registerQualityRails } from '@mosaicstack/quality-rails'; +import { registerMacpCommand } from '@mosaicstack/macp'; import { registerAgentCommand } from './commands/agent.js'; import { registerMissionCommand } from './commands/mission.js'; // prdy is registered via launch.ts @@ -314,6 +316,14 @@ registerAgentCommand(program); registerMissionCommand(program); +// ─── macp ──────────────────────────────────────────────────────────────── + +registerMacpCommand(program); + +// ─── forge ─────────────────────────────────────────────────────────────── + +registerForgeCommand(program); + // ─── quality-rails ────────────────────────────────────────────────────── registerQualityRails(program); diff --git a/packages/queue/package.json b/packages/queue/package.json index 2be52cc..2fe762f 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@mosaicstack/types": "workspace:*", + "commander": "^13.0.0", "ioredis": "^5.10.0" }, "devDependencies": { diff --git a/packages/queue/src/cli.spec.ts b/packages/queue/src/cli.spec.ts new file mode 100644 index 0000000..92db4e9 --- /dev/null +++ b/packages/queue/src/cli.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { registerQueueCommand } from './cli.js'; + +describe('registerQueueCommand', () => { + function buildProgram(): Command { + const program = new Command('mosaic'); + registerQueueCommand(program); + return program; + } + + it('registers a "queue" subcommand', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + expect(queueCmd).toBeDefined(); + }); + + it('queue has list, stats, pause, resume, jobs, drain subcommands', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + expect(queueCmd).toBeDefined(); + + const names = queueCmd!.commands.map((c) => c.name()); + expect(names).toContain('list'); + expect(names).toContain('stats'); + expect(names).toContain('pause'); + expect(names).toContain('resume'); + expect(names).toContain('jobs'); + expect(names).toContain('drain'); + }); + + it('jobs subcommand has a "tail" subcommand', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + const jobsCmd = queueCmd!.commands.find((c) => c.name() === 'jobs'); + expect(jobsCmd).toBeDefined(); + + const tailCmd = jobsCmd!.commands.find((c) => c.name() === 'tail'); + expect(tailCmd).toBeDefined(); + }); + + it('drain has a --yes option', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + const drainCmd = queueCmd!.commands.find((c) => c.name() === 'drain'); + expect(drainCmd).toBeDefined(); + + const optionNames = drainCmd!.options.map((o) => o.long); + expect(optionNames).toContain('--yes'); + }); + + it('stats accepts an optional [name] argument', () => { + const program = buildProgram(); + const queueCmd = program.commands.find((c) => c.name() === 'queue'); + const statsCmd = queueCmd!.commands.find((c) => c.name() === 'stats'); + expect(statsCmd).toBeDefined(); + // Should not throw when called without argument + const args = statsCmd!.registeredArguments; + expect(args.length).toBe(1); + expect(args[0]!.required).toBe(false); + }); +}); diff --git a/packages/queue/src/cli.ts b/packages/queue/src/cli.ts new file mode 100644 index 0000000..983e077 --- /dev/null +++ b/packages/queue/src/cli.ts @@ -0,0 +1,248 @@ +import type { Command } from 'commander'; + +import { createLocalAdapter } from './adapters/local.js'; +import type { QueueConfig } from './types.js'; + +/** Resolve adapter type from env; defaults to 'local'. */ +function resolveAdapterType(): 'bullmq' | 'local' { + const t = process.env['QUEUE_ADAPTER'] ?? 'local'; + return t === 'bullmq' ? 'bullmq' : 'local'; +} + +function resolveConfig(): QueueConfig { + const type = resolveAdapterType(); + if (type === 'bullmq') { + return { type: 'bullmq', url: process.env['VALKEY_URL'] }; + } + return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] }; +} + +const BULLMQ_ONLY_MSG = + 'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)'; + +/** + * Register queue subcommands on an existing Commander program. + * Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails. + */ +export function registerQueueCommand(parent: Command): void { + buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues')); +} + +function buildQueueCommand(queue: Command): void { + // ─── list ────────────────────────────────────────────────────────────── + queue + .command('list') + .description('List all queues known to the configured adapter') + .action(async () => { + const config = resolveConfig(); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + // Local adapter tracks queues in its internal Map; we expose them by + // listing JSON files in the data dir. + const { readdirSync } = await import('node:fs'); + const { existsSync } = await import('node:fs'); + const dataDir = config.dataDir ?? '.mosaic/queue'; + if (!existsSync(dataDir)) { + console.log('No queues found (data dir does not exist yet).'); + await adapter.close(); + return; + } + const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json')); + if (files.length === 0) { + console.log('No queues found.'); + } else { + console.log('Queues (local adapter):'); + for (const f of files) { + console.log(` - ${f.slice(0, -5)}`); + } + } + await adapter.close(); + return; + } + + // bullmq — not enough info to enumerate queues without a BullMQ Board + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── stats ───────────────────────────────────────────────────────────── + queue + .command('stats [name]') + .description('Show stats for a queue (or all queues)') + .action(async (name?: string) => { + const config = resolveConfig(); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + const { readdirSync } = await import('node:fs'); + const { existsSync } = await import('node:fs'); + const dataDir = config.dataDir ?? '.mosaic/queue'; + + let names: string[] = []; + if (name) { + names = [name]; + } else { + if (existsSync(dataDir)) { + names = readdirSync(dataDir) + .filter((f: string) => f.endsWith('.json')) + .map((f: string) => f.slice(0, -5)); + } + } + + if (names.length === 0) { + console.log('No queues found.'); + await adapter.close(); + return; + } + + for (const queueName of names) { + const len = await adapter.length(queueName); + console.log(`Queue: ${queueName}`); + console.log(` waiting: ${len}`); + console.log(` active: 0 (local adapter — no active tracking)`); + console.log(` completed: 0 (local adapter — no completed tracking)`); + console.log(` failed: 0 (local adapter — no failed tracking)`); + console.log(` delayed: 0 (local adapter — no delayed tracking)`); + } + await adapter.close(); + return; + } + + // bullmq + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── pause ───────────────────────────────────────────────────────────── + queue + .command('pause ') + .description('Pause job processing for a queue') + .action(async (_name: string) => { + const config = resolveConfig(); + + if (config.type === 'local') { + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + return; + } + + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── resume ──────────────────────────────────────────────────────────── + queue + .command('resume ') + .description('Resume job processing for a queue') + .action(async (_name: string) => { + const config = resolveConfig(); + + if (config.type === 'local') { + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + return; + } + + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── jobs tail ───────────────────────────────────────────────────────── + const jobs = queue.command('jobs').description('Job-level operations'); + + jobs + .command('tail [name]') + .description('Stream new jobs as they arrive (poll-based)') + .option('--interval ', 'Poll interval in ms', '2000') + .action(async (name: string | undefined, opts: { interval: string }) => { + const config = resolveConfig(); + const pollMs = parseInt(opts.interval, 10); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + const { existsSync, readdirSync } = await import('node:fs'); + const dataDir = config.dataDir ?? '.mosaic/queue'; + + let names: string[] = []; + if (name) { + names = [name]; + } else { + if (existsSync(dataDir)) { + names = readdirSync(dataDir) + .filter((f: string) => f.endsWith('.json')) + .map((f: string) => f.slice(0, -5)); + } + } + + if (names.length === 0) { + console.log('No queues to tail.'); + await adapter.close(); + return; + } + + console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`); + const lastLen = new Map(); + for (const qn of names) { + lastLen.set(qn, await adapter.length(qn)); + } + + const timer = setInterval(async () => { + for (const qn of names) { + const len = await adapter.length(qn); + const prev = lastLen.get(qn) ?? 0; + if (len > prev) { + console.log( + `[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`, + ); + } + lastLen.set(qn, len); + } + }, pollMs); + + process.on('SIGINT', async () => { + clearInterval(timer); + await adapter.close(); + process.exit(0); + }); + + return; + } + + // bullmq — use subscribe on the channel + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); + + // ─── drain ───────────────────────────────────────────────────────────── + queue + .command('drain ') + .description('Drain all pending jobs from a queue') + .option('--yes', 'Skip confirmation prompt') + .action(async (name: string, opts: { yes?: boolean }) => { + if (!opts.yes) { + console.error( + `WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`, + ); + process.exit(1); + return; + } + + const config = resolveConfig(); + + if (config.type === 'local') { + const adapter = createLocalAdapter(config); + let removed = 0; + while ((await adapter.length(name)) > 0) { + await adapter.dequeue(name); + removed++; + } + console.log(`Drained ${removed} job(s) from queue "${name}".`); + await adapter.close(); + return; + } + + console.log(BULLMQ_ONLY_MSG); + process.exit(0); + }); +} diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 0aa4089..98173b9 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -11,6 +11,7 @@ export { type QueueAdapter, type QueueConfig as QueueAdapterConfig } from './typ export { createQueueAdapter, registerQueueAdapter } from './factory.js'; export { createBullMQAdapter } from './adapters/bullmq.js'; export { createLocalAdapter } from './adapters/local.js'; +export { registerQueueCommand } from './cli.js'; import { registerQueueAdapter } from './factory.js'; import { createBullMQAdapter } from './adapters/bullmq.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df1291d..a58e689 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,9 @@ importers: '@mosaicstack/macp': specifier: workspace:* version: link:../macp + commander: + specifier: ^13.0.0 + version: 13.1.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -401,6 +404,9 @@ importers: '@mosaicstack/db': specifier: workspace:* version: link:../db + commander: + specifier: ^13.0.0 + version: 13.1.0 drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) @@ -413,6 +419,10 @@ importers: version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) packages/macp: + dependencies: + commander: + specifier: ^13.0.0 + version: 13.1.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -438,6 +448,9 @@ importers: '@mosaicstack/types': specifier: workspace:* version: link:../types + commander: + specifier: ^13.0.0 + version: 13.1.0 drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) @@ -565,6 +578,9 @@ importers: '@mosaicstack/types': specifier: workspace:* version: link:../types + commander: + specifier: ^13.0.0 + version: 13.1.0 ioredis: specifier: ^5.10.0 version: 5.10.0 @@ -634,10 +650,10 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) '@mariozechner/pi-ai': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 @@ -7012,6 +7028,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.73.0(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -8353,6 +8375,18 @@ snapshots: - ws - zod + '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8401,6 +8435,30 @@ snapshots: - ws - zod + '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.73.0(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1008.0 + '@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@mistralai/mistralai': 1.14.1 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + partial-json: 0.1.7 + proxy-agent: 6.5.0 + undici: 7.24.3 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - aws-crt + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -12776,6 +12834,11 @@ snapshots: dependencies: mimic-function: 5.0.1 + openai@6.26.0(ws@8.20.0)(zod@3.25.76): + optionalDependencies: + ws: 8.20.0 + zod: 3.25.76 + openai@6.26.0(ws@8.20.0)(zod@4.3.6): optionalDependencies: ws: 8.20.0