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/mosaic/src/auth.ts b/packages/mosaic/src/auth.ts index e9fe792..1996859 100644 --- a/packages/mosaic/src/auth.ts +++ b/packages/mosaic/src/auth.ts @@ -74,7 +74,8 @@ export function saveSession(gatewayUrl: string, auth: AuthResult): void { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days }; - writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8'); + // 0o600: owner read/write only — the session cookie is a credential + writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 }); } /** diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 5c1a07a..e885b3e 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -5,6 +5,7 @@ import { Command } from 'commander'; import { registerBrainCommand } from '@mosaicstack/brain'; import { registerForgeCommand } from '@mosaicstack/forge'; import { registerLogCommand } from '@mosaicstack/log'; +import { registerMacpCommand } from '@mosaicstack/macp'; import { registerMemoryCommand } from '@mosaicstack/memory'; import { registerQualityRails } from '@mosaicstack/quality-rails'; import { registerQueueCommand } from '@mosaicstack/queue'; @@ -357,6 +358,10 @@ registerBrainCommand(program); registerForgeCommand(program); +// ─── macp ──────────────────────────────────────────────────────────────── + +registerMacpCommand(program); + // ─── quality-rails ────────────────────────────────────────────────────── registerQualityRails(program); diff --git a/packages/mosaic/src/commands/gateway.ts b/packages/mosaic/src/commands/gateway.ts index 0bfb9ea..92fc9ef 100644 --- a/packages/mosaic/src/commands/gateway.ts +++ b/packages/mosaic/src/commands/gateway.ts @@ -126,10 +126,18 @@ export function registerGatewayCommand(program: Command): void { .description('Sign in to the gateway (defaults to URL from meta.json)') .option('-g, --gateway ', 'Gateway URL (overrides meta.json)') .option('-e, --email ', 'Email address') - .option('-p, --password ', 'Password') + .option( + '-p, --password ', + '[UNSAFE] Avoid — exposes credentials in shell history and process listings', + ) .action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => { const { runLogin } = await import('./gateway/login.js'); const url = getGatewayUrl(cmdOpts.gateway); + if (cmdOpts.password) { + console.warn( + 'Warning: --password flag exposes credentials in shell history and process listings.', + ); + } try { await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password }); } catch (err) { diff --git a/packages/mosaic/src/commands/gateway/login.ts b/packages/mosaic/src/commands/gateway/login.ts index fd3730a..109a1ae 100644 --- a/packages/mosaic/src/commands/gateway/login.ts +++ b/packages/mosaic/src/commands/gateway/login.ts @@ -2,6 +2,62 @@ import { createInterface } from 'node:readline'; import { signIn, saveSession } from '../../auth.js'; import { readMeta } from './daemon.js'; +/** + * Prompt for a single line of input (with echo). + */ +export function promptLine(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +/** + * Prompt for a secret value without echoing the typed characters to the terminal. + * Uses TTY raw mode when available so that passwords do not appear in terminal + * recordings, scrollback, or shared screen sessions. + */ +export function promptSecret(question: string): Promise { + return new Promise((resolve) => { + process.stdout.write(question); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.setEncoding('utf-8'); + + let secret = ''; + const onData = (char: string): void => { + if (char === '\n' || char === '\r' || char === '\u0004') { + process.stdout.write('\n'); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + process.stdin.removeListener('data', onData); + resolve(secret); + } else if (char === '\u0003') { + // ^C + process.stdout.write('\n'); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + process.stdin.removeListener('data', onData); + process.exit(130); + } else if (char === '\u007f' || char === '\b') { + secret = secret.slice(0, -1); + } else { + secret += char; + } + }; + process.stdin.on('data', onData); + }); +} + /** * Shared login helper used by both `mosaic login` and `mosaic gateway login`. * Prompts for email/password if not supplied, signs in, and persists the session. @@ -11,17 +67,9 @@ export async function runLogin(opts: { email?: string; password?: string; }): Promise { - let email = opts.email; - let password = opts.password; - - if (!email || !password) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); - - if (!email) email = await ask('Email: '); - if (!password) password = await ask('Password: '); - rl.close(); - } + const email = opts.email ?? (await promptLine('Email: ')); + // Do not trim password — it may intentionally contain leading/trailing whitespace + const password = opts.password ?? (await promptSecret('Password: ')); const auth = await signIn(opts.gatewayUrl, email, password); saveSession(opts.gatewayUrl, auth); diff --git a/packages/mosaic/src/commands/gateway/recover-token.spec.ts b/packages/mosaic/src/commands/gateway/recover-token.spec.ts index aeb45d4..d00bc0d 100644 --- a/packages/mosaic/src/commands/gateway/recover-token.spec.ts +++ b/packages/mosaic/src/commands/gateway/recover-token.spec.ts @@ -16,14 +16,9 @@ vi.mock('./daemon.js', () => ({ vi.mock('./login.js', () => ({ getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), -})); - -// Mock readline so tests don't block on stdin -vi.mock('node:readline', () => ({ - createInterface: vi.fn().mockReturnValue({ - question: vi.fn((_q: string, cb: (a: string) => void) => cb('test-input')), - close: vi.fn(), - }), + // promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin + promptLine: vi.fn().mockResolvedValue('test@example.com'), + promptSecret: vi.fn().mockResolvedValue('test-password'), })); const mockFetch = vi.fn(); diff --git a/packages/mosaic/src/commands/gateway/token-ops.ts b/packages/mosaic/src/commands/gateway/token-ops.ts index 2fd6005..412dd31 100644 --- a/packages/mosaic/src/commands/gateway/token-ops.ts +++ b/packages/mosaic/src/commands/gateway/token-ops.ts @@ -1,7 +1,6 @@ -import { createInterface } from 'node:readline'; import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; import { readMeta, writeMeta } from './daemon.js'; -import { getGatewayUrl } from './login.js'; +import { getGatewayUrl, promptLine, promptSecret } from './login.js'; interface MintedToken { id: string; @@ -58,6 +57,9 @@ export async function mintAdminToken( /** * Persist the new token into meta.json and print the confirmation banner. + * + * Emits a warning when the target gateway differs from the locally installed one, + * so operators are aware that meta.json may not reflect the intended gateway. */ export function persistToken(gatewayUrl: string, minted: MintedToken): void { const meta = readMeta() ?? { @@ -68,6 +70,15 @@ export function persistToken(gatewayUrl: string, minted: MintedToken): void { port: parseInt(new URL(gatewayUrl).port || '14242', 10), }; + // Warn when the target gateway does not match the locally installed one + const targetHost = new URL(gatewayUrl).hostname; + if (targetHost !== meta.host) { + console.warn( + `Warning: token was minted against ${gatewayUrl} but is being saved to the local` + + ` meta.json (host: ${meta.host}). Copy the token manually if targeting a remote gateway.`, + ); + } + writeMeta({ ...meta, adminToken: minted.plaintext }); const preview = `${minted.plaintext.slice(0, 8)}...`; @@ -108,13 +119,10 @@ export async function ensureSession(gatewayUrl: string): Promise { console.log(`No session found for ${gatewayUrl}. Please sign in.`); } - // Prompt for credentials - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); - - const email = (await ask('Email: ')).trim(); - const password = (await ask('Password: ')).trim(); - rl.close(); + // Prompt for credentials — password must not be echoed to the terminal + const email = await promptLine('Email: '); + // Do not trim password — it may contain intentional leading/trailing whitespace + const password = await promptSecret('Password: '); const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 053413d..ba52099 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,6 +422,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 @@ -667,10 +671,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@3.25.76) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@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@3.25.76) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 @@ -7045,12 +7049,6 @@ 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 @@ -8392,18 +8390,6 @@ 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) @@ -8452,30 +8438,6 @@ 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) @@ -12851,11 +12813,6 @@ 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