From 406b3fa047cb3693179ab7c97126ffe91921f804 Mon Sep 17 00:00:00 2001 From: Krishnamurti Pandaram Date: Mon, 27 Jul 2026 21:15:24 +0530 Subject: [PATCH 1/4] Add source-checkout CLI task adapter --- docs/cli-reference.md | 10 + .../2026-07-27-issue-22-cli-task-adapter.md | 172 ++++++++++++++++++ package.json | 2 + src/interfaces/cli/main.ts | 10 + src/interfaces/cli/output/cli-errors.ts | 16 ++ src/interfaces/cli/output/cli-result.ts | 9 + src/interfaces/cli/run-cli.ts | 68 +++++++ tests/unit/interfaces/cli/run-cli.test.ts | 70 +++++++ tsup.config.ts | 1 + 9 files changed, 358 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md create mode 100644 src/interfaces/cli/main.ts create mode 100644 src/interfaces/cli/output/cli-errors.ts create mode 100644 src/interfaces/cli/output/cli-result.ts create mode 100644 src/interfaces/cli/run-cli.ts create mode 100644 tests/unit/interfaces/cli/run-cli.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ebac529..b897627 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -12,6 +12,16 @@ relay session ... `relay-mcp` may remain as a compatibility entry point, but new integrations target `relay mcp`. +## Source-checkout invocation + +Build the project, then invoke `node dist/cli/main.js` from any directory. Set `RELAY_DB_PATH` when an explicit database location is needed: + +```text +RELAY_DB_PATH=/tmp/relay.db node /path/to/relay/dist/cli/main.js task list --output json +``` + +The task and session commands below call `TaskApplication` directly. They never start HTTP or MCP processes. + ## JSON protocol Every agent-facing command accepts `--output json`. JSON mode writes one JSON document followed by a newline to stdout; diagnostics are written to stderr. No caller needs to parse decorative output. diff --git a/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md b/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md new file mode 100644 index 0000000..fab9b20 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md @@ -0,0 +1,172 @@ +# Issue #22 CLI Task Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the source-checkout `relay` CLI task/session adapter with the same versioned results and mutation semantics as MCP. + +**Architecture:** `runCli(argv, dependencies)` will strictly parse an agent-facing task/session command before constructing the shared `TaskRuntime`. Command handlers call `TaskApplication` directly and build the #19 JSON envelope through shared CLI output/error mappers; `main.ts` only adapts Node process streams and exit codes. The adapter reuses task DTO and mutation-change mappers, never HTTP, MCP, or SQLite APIs. + +**Tech Stack:** TypeScript, Node.js 24, Vitest, Zod contract schemas, tsup, pnpm. + +## Global Constraints + +- JSON mode writes exactly one schema-versioned JSON document plus a newline to stdout; diagnostics go only to stderr. +- Exit codes are stable: `0` success, `1` internal, `2` usage/validation, `3` not found, `4` conflict/archived, `5` storage. +- Create a shared runtime only after parsing succeeds and close it exactly once on every execution path. +- Invoke `TaskApplication` directly; do not call HTTP, spawn MCP, access SQLite directly, or create CLI-specific mutation behavior. +- Support exactly the ten commands and only documented options; reject unknown commands, options, duplicate singular options, and missing values. +- `RELAY_DB_PATH` supplies isolated storage through the shared runtime, independent of the process CWD. + +--- + +### Task 1: Strict command parser and envelope/runtime boundary + +**Files:** +- Create: `src/interfaces/cli/parse-cli.ts`, `src/interfaces/cli/run-cli.ts`, `src/interfaces/cli/output/cli-result.ts`, `src/interfaces/cli/output/cli-errors.ts`, `tests/unit/interfaces/cli/run-cli.test.ts` +- Modify: `src/interfaces/contracts/task-contract.ts` only if an existing schema needs re-exporting for CLI validation. + +**Interfaces:** +- Consumes: `TaskApplication`, `TaskRuntime`, `CONTRACT_SCHEMA_VERSION`, the task contract schemas, and MCP's `toTaskMcpDto`, `editChange`, `triageChange`, `lifecycleChange`. +- Produces: `runCli(argv, { createRuntime, stdout, stderr }): Promise` and parsed command union with command-specific validated input. + +- [ ] **Step 1: Write failing parser/output/lifecycle tests.** + +```ts +await expect(runCli(['task', 'get', 'id', '--output', 'json'], deps)).resolves.toBe(0); +expect(stdout).toBe(`${JSON.stringify({ schemaVersion: 1, ok: true, data: { task }, warnings: [] })}\n`); +expect(createRuntime).toHaveBeenCalledTimes(1); +expect(runtime.close).toHaveBeenCalledTimes(1); +await expect(runCli(['task', 'get'], deps)).resolves.toBe(2); +expect(createRuntime).not.toHaveBeenCalled(); +``` + +- [ ] **Step 2: Run the focused test and confirm it fails because the CLI module does not exist.** + +Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` + +- [ ] **Step 3: Implement strict parser, success/error envelopes, error-to-exit mapping, and one-close runtime execution wrapper.** + +```ts +type CliEnvelope = { schemaVersion: number; ok: boolean; data?: Record; warnings?: readonly unknown[]; error?: { code: string; message: string } }; +export async function runCli(argv: readonly string[], dependencies: CliDependencies): Promise { + const command = parseCli(argv); + const runtime = dependencies.createRuntime(); + try { return execute(command, runtime.taskApplication, dependencies); } + catch (error) { return writeCliError(error, dependencies.stderr, dependencies.stdout); } + finally { runtime.close(); } +} +``` + +- [ ] **Step 4: Re-run focused tests and confirm valid JSON has no stderr and every parser failure avoids runtime startup.** + +Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` + +### Task 2: Read and capture command handlers + +**Files:** +- Create: `src/interfaces/cli/commands/task-capture.ts`, `src/interfaces/cli/commands/task-list.ts`, `src/interfaces/cli/commands/task-get.ts`, `src/interfaces/cli/commands/task-find-similar.ts`, `src/interfaces/cli/commands/session-captures.ts` +- Modify: `src/interfaces/cli/run-cli.ts`, `tests/unit/interfaces/cli/run-cli.test.ts` + +**Interfaces:** +- Consumes: parsed commands, `TaskApplication.create/list/get/findSimilar/listSessionCaptures`, `toTaskMcpDto`, and `matchReason`. +- Produces: `{ task, change: { action: 'CREATED' } }`, `{ tasks, count }`, `{ candidates }`, and `{ sessionId, tasks, count }` payloads identical to MCP equivalents. + +- [ ] **Step 1: Add failing tests for every read/capture command, strict limits/statuses, and advisory duplicate warnings.** + +```ts +await runCli(['task', 'capture', '--title', 'Release', '--agent', 'codex', '--session', 'session-1', '--output', 'json'], deps); +expect(application.findSimilar).toHaveBeenCalledWith({ title: 'Release', limit: 5 }); +expect(application.create).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-1', creator: { type: 'AGENT', name: 'codex' } })); +expect(json(stdout).warnings).toEqual([{ code: 'POSSIBLE_DUPLICATE', message: 'Similar tasks already exist.', candidates: [{ id: 'existing' }] }]); +``` + +- [ ] **Step 2: Run focused tests and confirm they fail for the unimplemented dispatch paths.** + +Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` + +- [ ] **Step 3: Implement handlers that call the corresponding application method exactly once and serialize MCP-parity payloads.** + +```ts +const candidates = application.findSimilar({ title: command.title, ...(command.workspace === undefined ? {} : { workspace: command.workspace }), limit: command.limit }); +return success({ candidates: candidates.map((task) => ({ task: toTaskMcpDto(task), matchReason: matchReason(task, command.title) })) }); +``` + +- [ ] **Step 4: Re-run focused tests.** + +Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` + +### Task 3: Mutation command handlers + +**Files:** +- Create: `src/interfaces/cli/commands/task-edit.ts`, `src/interfaces/cli/commands/task-triage.ts`, `src/interfaces/cli/commands/task-start.ts`, `src/interfaces/cli/commands/task-complete.ts`, `src/interfaces/cli/commands/task-archive.ts` +- Modify: `src/interfaces/cli/run-cli.ts`, `tests/unit/interfaces/cli/run-cli.test.ts` + +**Interfaces:** +- Consumes: `TaskApplication.edit/moveToInbox/activate/moveToBacklog/start/complete/archive` and MCP change-metadata mappers. +- Produces: mutation payload `{ task, change }` with exactly the #21 `EDITED`, `TRIAGED`, lifecycle, and `NO_CHANGE` shapes. + +- [ ] **Step 1: Add failing tests for edit field/clear conflicts, no-op metadata, three permitted triage targets, and lifecycle errors.** + +```ts +await runCli(['task', 'edit', 'id', '--clear-description', '--output', 'json'], deps); +expect(application.edit).toHaveBeenCalledWith({ id: 'id', description: null }); +await expect(runCli(['task', 'triage', 'id', '--to', 'DONE', '--output', 'json'], deps)).resolves.toBe(2); +expect(json(stdout).error.code).toBe('VALIDATION_ERROR'); +``` + +- [ ] **Step 2: Run focused tests and confirm mutation dispatch is not yet available.** + +Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` + +- [ ] **Step 3: Implement narrow mutation handlers and reuse existing metadata mappers without generic status mutation.** + +```ts +const mutation = command.target === 'INBOX' ? application.moveToInbox({ id: command.id }) : command.target === 'ACTIVE' ? application.activate({ id: command.id }) : application.moveToBacklog({ id: command.id }); +return success({ task: toTaskMcpDto(mutation.task), change: triageChange(mutation.before, mutation.task) }); +``` + +- [ ] **Step 4: Re-run focused tests and verify all stable exit categories.** + +Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` + +### Task 4: Executable packaging, process integration, parity, and documentation + +**Files:** +- Create: `src/interfaces/cli/main.ts`, `tests/integration/cli.test.ts`, `tests/integration/mcp-cli-parity.test.ts` +- Modify: `package.json`, `tsup.config.ts`, `tests/unit/scripts/validate-repository-assets.test.ts`, `scripts/validate-repository-assets.ts`, `docs/cli-reference.md`, `README.md` + +**Interfaces:** +- Consumes: `runCli`, `createTaskRuntime`, Node process argv/stdout/stderr, built `dist/cli/main.js`, and existing MCP fixture envelopes. +- Produces: a `relay` bin entry and built CLI executable that operates from an arbitrary CWD with `RELAY_DB_PATH`. + +- [ ] **Step 1: Add failing built-process and MCP/CLI parity tests.** + +```ts +const result = spawnSync(process.execPath, [builtCliPath, 'task', 'list', '--output', 'json'], { cwd: launchDir, env: { ...process.env, RELAY_DB_PATH: databasePath } }); +expect(result.status).toBe(0); +expect(JSON.parse(result.stdout.toString())).toMatchObject({ schemaVersion: 1, ok: true }); +expect(result.stderr.toString()).toBe(''); +``` + +- [ ] **Step 2: Run integration tests and confirm they fail because the CLI entry/build asset is absent.** + +Run: `pnpm test -- tests/integration/cli.test.ts tests/integration/mcp-cli-parity.test.ts` + +- [ ] **Step 3: Add `main.ts`, the `relay` bin/build entries, asset validation, CLI reference, and README invocation guidance.** + +```ts +void runCli(process.argv.slice(2), { createRuntime: createTaskRuntime, stdout: process.stdout, stderr: process.stderr }) + .then((exitCode) => { process.exitCode = exitCode; }); +``` + +- [ ] **Step 4: Run the focused unit/integration suite, then the full issue verification gate.** + +Run: `pnpm test -- tests/unit/interfaces/cli tests/integration/cli.test.ts tests/integration/mcp-cli-parity.test.ts` + +Run: `pnpm verify` + +## Plan self-review + +- Coverage: Tasks 1–3 implement the specified parser, envelopes, lifecycle, all ten commands, direct application invocation, validation, exit codes, and change metadata. Task 4 covers source-checkout build/bin, arbitrary-CWD/isolated database operation, MCP parity, assets, docs, and full verification. +- No placeholders: all command families, interfaces, expected tests, and verification commands are explicit. +- Consistency: every handler uses `TaskApplication`; task DTO and mutation metadata remain the existing MCP-compatible names. diff --git a/package.json b/package.json index a228609..3061d8c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "packageManager": "pnpm@10.2.0", "bin": { + "relay": "./dist/cli/main.js", "relay-mcp": "./dist/mcp/main.js" }, "scripts": { @@ -23,6 +24,7 @@ "build:clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "build": "pnpm build:clean && pnpm build:node && pnpm build:web", "dev:mcp": "node --import tsx/esm src/interfaces/mcp/main.ts", + "dev:cli": "node --import tsx/esm src/interfaces/cli/main.ts", "dev:http": "node --import tsx/esm src/interfaces/http/main.ts", "dev:web": "vite", "dev:ui": "concurrently -k -p name -c \"blue,green\" \"pnpm dev:http\" \"pnpm dev:web\"", diff --git a/src/interfaces/cli/main.ts b/src/interfaces/cli/main.ts new file mode 100644 index 0000000..4e3ea69 --- /dev/null +++ b/src/interfaces/cli/main.ts @@ -0,0 +1,10 @@ +import { createTaskRuntime } from '../shared/create-task-runtime.js'; +import { runCli } from './run-cli.js'; + +void runCli(process.argv.slice(2), { + createRuntime: createTaskRuntime, + stdout: process.stdout, + stderr: process.stderr, +}).then((exitCode) => { + process.exitCode = exitCode; +}); diff --git a/src/interfaces/cli/output/cli-errors.ts b/src/interfaces/cli/output/cli-errors.ts new file mode 100644 index 0000000..8f028d9 --- /dev/null +++ b/src/interfaces/cli/output/cli-errors.ts @@ -0,0 +1,16 @@ +import { ZodError } from 'zod'; +import { InvalidTaskRequestError, TaskNotFoundError, TaskPersistenceError } from '../../../application/tasks/task-application-errors.js'; +import { TaskArchivedError, TaskDomainError, TaskTransitionError } from '../../../domain/task/task-errors.js'; + +export interface CliMappedError { readonly code: string; readonly message: string; readonly exitCode: number; } +export class CliUsageError extends Error {} + +export function toCliError(error: unknown): CliMappedError { + if (error instanceof TaskArchivedError) return { code: 'ARCHIVED_TASK', message: 'The task is archived.', exitCode: 4 }; + if (error instanceof TaskTransitionError) return { code: 'CONFLICT', message: 'Task lifecycle transition is not allowed.', exitCode: 4 }; + if (error instanceof TaskNotFoundError) return { code: 'NOT_FOUND', message: 'Task was not found.', exitCode: 3 }; + if (error instanceof TaskPersistenceError) return { code: 'STORAGE_ERROR', message: 'Task storage operation failed.', exitCode: 5 }; + if (error instanceof CliUsageError || error instanceof ZodError || error instanceof InvalidTaskRequestError || error instanceof TaskDomainError) + return { code: 'VALIDATION_ERROR', message: error instanceof CliUsageError ? error.message : 'Request validation failed.', exitCode: 2 }; + return { code: 'INTERNAL_ERROR', message: 'An unexpected internal error occurred.', exitCode: 1 }; +} diff --git a/src/interfaces/cli/output/cli-result.ts b/src/interfaces/cli/output/cli-result.ts new file mode 100644 index 0000000..66b959d --- /dev/null +++ b/src/interfaces/cli/output/cli-result.ts @@ -0,0 +1,9 @@ +import { CONTRACT_SCHEMA_VERSION } from '../../contracts/contract-version.js'; + +export function cliSuccess(data: Record, warnings: readonly unknown[] = []) { + return { schemaVersion: CONTRACT_SCHEMA_VERSION, ok: true, data, warnings }; +} + +export function cliFailure(code: string, message: string) { + return { schemaVersion: CONTRACT_SCHEMA_VERSION, ok: false, error: { code, message } }; +} diff --git a/src/interfaces/cli/run-cli.ts b/src/interfaces/cli/run-cli.ts new file mode 100644 index 0000000..21ea80c --- /dev/null +++ b/src/interfaces/cli/run-cli.ts @@ -0,0 +1,68 @@ +import type { CreateTaskInput, EditTaskInput, TaskApplication } from '../../application/tasks/task-application.js'; +import type { TaskPriority } from '../../domain/task/task-priority.js'; +import { TASK_STATUSES, type TaskStatus } from '../../domain/task/task-status.js'; +import type { TaskRuntime } from '../shared/create-task-runtime.js'; +import { editChange, lifecycleChange, triageChange } from '../mcp/mapping/change-metadata.js'; +import { matchReason, toTaskMcpDto } from '../mcp/mapping/task-mcp-dto.js'; +import { CliUsageError, toCliError } from './output/cli-errors.js'; +import { cliFailure, cliSuccess } from './output/cli-result.js'; + +type Writer = { write(text: string): unknown }; +export interface CliDependencies { readonly createRuntime: () => TaskRuntime; readonly stdout: Writer; readonly stderr: Writer; } +type Command = { readonly group: 'task' | 'session'; readonly action: string; readonly id?: string; readonly options: ReadonlyMap; }; + +export async function runCli(argv: readonly string[], dependencies: CliDependencies): Promise { + try { + const command = parse(argv); + const runtime = dependencies.createRuntime(); + try { + const result = execute(command, runtime.taskApplication); + const { warnings = [], ...data } = result; + write(dependencies.stdout, cliSuccess(data, warnings as readonly unknown[])); + return 0; + } + catch (error) { return writeError(error, dependencies); } + finally { runtime.close(); } + } catch (error) { return writeError(error, dependencies); } +} + +function writeError(error: unknown, { stdout, stderr }: CliDependencies): number { + const mapped = toCliError(error); write(stdout, cliFailure(mapped.code, mapped.message)); stderr.write(`${mapped.message}\n`); return mapped.exitCode; +} +function write(writer: Writer, value: unknown): void { writer.write(`${JSON.stringify(value)}\n`); } + +function parse(argv: readonly string[]): Command { + const [group, action, ...rest] = argv; + if ((group !== 'task' && group !== 'session') || !action) throw new CliUsageError('Unknown or missing command.'); + const needsId = group === 'task' && ['get', 'edit', 'triage', 'start', 'complete', 'archive'].includes(action); + const id = needsId ? rest.shift() : undefined; + if (needsId && (!id || id.startsWith('--'))) throw new CliUsageError('A task id is required.'); + const options = new Map(); + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; if (!token?.startsWith('--')) throw new CliUsageError(`Unexpected argument: ${token}`); + const key = token.slice(2); const flags = new Set(['clear-description', 'clear-priority', 'clear-workspace', 'clear-source-context']); + const value = flags.has(key) ? 'true' : rest[++index]; + if (value === undefined || value.startsWith('--')) throw new CliUsageError(`Missing value for --${key}.`); + const existing = options.get(key) ?? []; if (key !== 'status' && existing.length) throw new CliUsageError(`Option --${key} may be supplied only once.`); options.set(key, [...existing, value]); + } + if (option(options, 'output', false) !== 'json') throw new CliUsageError('All supported commands require --output json.'); + return { group, action, ...(id === undefined ? {} : { id }), options }; +} +function option(options: ReadonlyMap, key: string, required = false): string | undefined { const value = options.get(key)?.[0]; if (required && value === undefined) throw new CliUsageError(`Missing required option --${key}.`); return value; } +function numberOption(options: ReadonlyMap, key: string, maximum: number, fallback: number): number { const raw = option(options, key); if (raw === undefined) return fallback; const value = Number(raw); if (!Number.isInteger(value) || value < 1 || value > maximum) throw new CliUsageError(`--${key} must be an integer from 1 through ${maximum}.`); return value; } +function optionalFields(command: Command): Omit { const o = command.options; const description = option(o, 'description'); const priority = option(o, 'priority'); const workspace = option(o, 'workspace'); const sourceContext = option(o, 'source-context'); return { ...(description === undefined ? {} : { description }), ...(priority === undefined ? {} : { priority: priority as TaskPriority }), ...(workspace === undefined ? {} : { workspace }), ...(sourceContext === undefined ? {} : { sourceContext }) }; } +function workspaceOption(options: ReadonlyMap) { const workspace = option(options, 'workspace'); return workspace === undefined ? {} : { workspace }; } + +function execute(command: Command, application: TaskApplication): Record { + const o = command.options; + if (command.group === 'session' && command.action === 'captures') { const sessionId = option(o, 'session', true)!; const tasks = application.listSessionCaptures({ sessionId, limit: numberOption(o, 'limit', 100, 100) }); return { sessionId, tasks: tasks.map(toTaskMcpDto), count: tasks.length }; } + if (command.group !== 'task') throw new CliUsageError('Unknown command.'); + if (command.action === 'get') return { task: toTaskMcpDto(application.get({ id: command.id! })) }; + if (command.action === 'list') { const statuses = (o.get('status') ?? [...TASK_STATUSES]) as readonly TaskStatus[]; const tasks = application.list({ statuses, limit: numberOption(o, 'limit', 100, 100), ...workspaceOption(o) }); return { tasks: tasks.map(toTaskMcpDto), count: tasks.length }; } + if (command.action === 'find-similar') { const title = option(o, 'title', true)!; const candidates = application.findSimilar({ title, limit: numberOption(o, 'limit', 5, 5), ...workspaceOption(o) }); return { candidates: candidates.map((task) => ({ task: toTaskMcpDto(task), matchReason: matchReason(task, title) })) }; } + if (command.action === 'capture') { const title = option(o, 'title', true)!; const matches = application.findSimilar({ title, limit: 5, ...workspaceOption(o) }); const task = application.create({ title, ...optionalFields(command), sessionId: option(o, 'session', true)!, creator: { type: 'AGENT', name: option(o, 'agent', true)! } }); return { task: toTaskMcpDto(task), change: { action: 'CREATED' }, warnings: matches.length ? [{ code: 'POSSIBLE_DUPLICATE', message: 'Similar tasks already exist.', candidates: matches.map(({ id }) => ({ id })) }] : [] }; } + if (command.action === 'edit') { const fields = optionalFields(command); const clears = { ...(o.has('clear-description') ? { description: null } : {}), ...(o.has('clear-priority') ? { priority: null } : {}), ...(o.has('clear-workspace') ? { workspace: null } : {}), ...(o.has('clear-source-context') ? { sourceContext: null } : {}) }; const title = option(o, 'title'); if (!Object.keys(fields).length && !Object.keys(clears).length && title === undefined) throw new CliUsageError('At least one editable task field is required.'); const mutation = application.edit({ id: command.id!, ...(title === undefined ? {} : { title }), ...fields, ...clears } as EditTaskInput); return { task: toTaskMcpDto(mutation.task), change: editChange(mutation.before, mutation.task) }; } + if (command.action === 'triage') { const target = option(o, 'to', true)!; const mutation = target === 'INBOX' ? application.moveToInbox({ id: command.id! }) : target === 'ACTIVE' ? application.activate({ id: command.id! }) : target === 'BACKLOG' ? application.moveToBacklog({ id: command.id! }) : (() => { throw new CliUsageError('--to must be INBOX, ACTIVE, or BACKLOG.'); })(); return { task: toTaskMcpDto(mutation.task), change: triageChange(mutation.before, mutation.task) }; } + const methods = { start: ['start', 'STARTED'], complete: ['complete', 'COMPLETED'], archive: ['archive', 'ARCHIVED'] } as const; + const method = methods[command.action as keyof typeof methods]; if (!method) throw new CliUsageError('Unknown command.'); const mutation = application[method[0]]({ id: command.id! }); return { task: toTaskMcpDto(mutation.task), change: lifecycleChange(mutation.before, mutation.task, method[1]) }; +} diff --git a/tests/unit/interfaces/cli/run-cli.test.ts b/tests/unit/interfaces/cli/run-cli.test.ts new file mode 100644 index 0000000..797be26 --- /dev/null +++ b/tests/unit/interfaces/cli/run-cli.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TaskApplication } from '../../../../src/application/tasks/task-application.js'; +import type { TaskRuntime } from '../../../../src/interfaces/shared/create-task-runtime.js'; +import { runCli } from '../../../../src/interfaces/cli/run-cli.js'; + +describe('runCli', () => { + it('writes one JSON result and closes the shared runtime once', async () => { + const stdout = { write: vi.fn() }; + const stderr = { write: vi.fn() }; + const runtime: TaskRuntime = { + taskApplication: { + get: vi.fn(() => ({ id: 'task-1', title: 'Task' })), + } as unknown as TaskApplication, + close: vi.fn(), + }; + + await expect( + runCli(['task', 'get', 'task-1', '--output', 'json'], { + createRuntime: vi.fn(() => runtime), + stdout, + stderr, + }), + ).resolves.toBe(0); + + expect(stdout.write).toHaveBeenCalledOnce(); + expect(JSON.parse(stdout.write.mock.calls[0]?.[0] as string)).toMatchObject({ + schemaVersion: 1, + ok: true, + data: { task: { id: 'task-1' } }, + warnings: [], + }); + expect(stderr.write).not.toHaveBeenCalled(); + expect(runtime.close).toHaveBeenCalledOnce(); + }); + + it('reports syntax failures without starting a runtime', async () => { + const createRuntime = vi.fn(); + const stdout = { write: vi.fn() }; + const stderr = { write: vi.fn() }; + + await expect(runCli(['task', 'get'], { createRuntime, stdout, stderr })).resolves.toBe(2); + + expect(createRuntime).not.toHaveBeenCalled(); + expect(stdout.write).toHaveBeenCalledOnce(); + expect(JSON.parse(stdout.write.mock.calls[0]?.[0] as string)).toMatchObject({ + schemaVersion: 1, + ok: false, + error: { code: 'VALIDATION_ERROR' }, + }); + expect(stderr.write).toHaveBeenCalledOnce(); + }); + + it('puts capture duplicate warnings in the envelope warning array', async () => { + const stdout = { write: vi.fn() }; + const runtime: TaskRuntime = { + taskApplication: { + findSimilar: vi.fn(() => [{ id: 'existing' }]), + create: vi.fn(() => ({ id: 'created', title: 'Task' })), + } as unknown as TaskApplication, + close: vi.fn(), + }; + await runCli( + ['task', 'capture', '--title', 'Task', '--agent', 'codex', '--session', 'session-1', '--output', 'json'], + { createRuntime: () => runtime, stdout, stderr: { write: vi.fn() } }, + ); + expect(JSON.parse(stdout.write.mock.calls[0]?.[0] as string).warnings).toEqual([ + { code: 'POSSIBLE_DUPLICATE', message: 'Similar tasks already exist.', candidates: [{ id: 'existing' }] }, + ]); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 799e767..61b6cc4 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: { + 'cli/main': 'src/interfaces/cli/main.ts', 'mcp/main': 'src/interfaces/mcp/main.ts', 'http/main': 'src/interfaces/http/main.ts', }, From 8a0d32786eacadd64dfaf17781529b721636710e Mon Sep 17 00:00:00 2001 From: Krishnamurti Pandaram Date: Mon, 27 Jul 2026 21:25:12 +0530 Subject: [PATCH 2/4] Format repository sources --- .../2026-07-27-issue-22-cli-task-adapter.md | 106 ++++++-- src/interfaces/cli/output/cli-errors.ts | 43 +++- src/interfaces/cli/run-cli.ts | 229 +++++++++++++++--- tests/unit/interfaces/cli/run-cli.test.ts | 19 +- 4 files changed, 341 insertions(+), 56 deletions(-) diff --git a/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md b/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md index fab9b20..2813936 100644 --- a/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md +++ b/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md @@ -22,10 +22,12 @@ ### Task 1: Strict command parser and envelope/runtime boundary **Files:** + - Create: `src/interfaces/cli/parse-cli.ts`, `src/interfaces/cli/run-cli.ts`, `src/interfaces/cli/output/cli-result.ts`, `src/interfaces/cli/output/cli-errors.ts`, `tests/unit/interfaces/cli/run-cli.test.ts` - Modify: `src/interfaces/contracts/task-contract.ts` only if an existing schema needs re-exporting for CLI validation. **Interfaces:** + - Consumes: `TaskApplication`, `TaskRuntime`, `CONTRACT_SCHEMA_VERSION`, the task contract schemas, and MCP's `toTaskMcpDto`, `editChange`, `triageChange`, `lifecycleChange`. - Produces: `runCli(argv, { createRuntime, stdout, stderr }): Promise` and parsed command union with command-specific validated input. @@ -33,7 +35,9 @@ ```ts await expect(runCli(['task', 'get', 'id', '--output', 'json'], deps)).resolves.toBe(0); -expect(stdout).toBe(`${JSON.stringify({ schemaVersion: 1, ok: true, data: { task }, warnings: [] })}\n`); +expect(stdout).toBe( + `${JSON.stringify({ schemaVersion: 1, ok: true, data: { task }, warnings: [] })}\n`, +); expect(createRuntime).toHaveBeenCalledTimes(1); expect(runtime.close).toHaveBeenCalledTimes(1); await expect(runCli(['task', 'get'], deps)).resolves.toBe(2); @@ -47,13 +51,26 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` - [ ] **Step 3: Implement strict parser, success/error envelopes, error-to-exit mapping, and one-close runtime execution wrapper.** ```ts -type CliEnvelope = { schemaVersion: number; ok: boolean; data?: Record; warnings?: readonly unknown[]; error?: { code: string; message: string } }; -export async function runCli(argv: readonly string[], dependencies: CliDependencies): Promise { +type CliEnvelope = { + schemaVersion: number; + ok: boolean; + data?: Record; + warnings?: readonly unknown[]; + error?: { code: string; message: string }; +}; +export async function runCli( + argv: readonly string[], + dependencies: CliDependencies, +): Promise { const command = parseCli(argv); const runtime = dependencies.createRuntime(); - try { return execute(command, runtime.taskApplication, dependencies); } - catch (error) { return writeCliError(error, dependencies.stderr, dependencies.stdout); } - finally { runtime.close(); } + try { + return execute(command, runtime.taskApplication, dependencies); + } catch (error) { + return writeCliError(error, dependencies.stderr, dependencies.stdout); + } finally { + runtime.close(); + } } ``` @@ -64,20 +81,44 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` ### Task 2: Read and capture command handlers **Files:** + - Create: `src/interfaces/cli/commands/task-capture.ts`, `src/interfaces/cli/commands/task-list.ts`, `src/interfaces/cli/commands/task-get.ts`, `src/interfaces/cli/commands/task-find-similar.ts`, `src/interfaces/cli/commands/session-captures.ts` - Modify: `src/interfaces/cli/run-cli.ts`, `tests/unit/interfaces/cli/run-cli.test.ts` **Interfaces:** + - Consumes: parsed commands, `TaskApplication.create/list/get/findSimilar/listSessionCaptures`, `toTaskMcpDto`, and `matchReason`. - Produces: `{ task, change: { action: 'CREATED' } }`, `{ tasks, count }`, `{ candidates }`, and `{ sessionId, tasks, count }` payloads identical to MCP equivalents. - [ ] **Step 1: Add failing tests for every read/capture command, strict limits/statuses, and advisory duplicate warnings.** ```ts -await runCli(['task', 'capture', '--title', 'Release', '--agent', 'codex', '--session', 'session-1', '--output', 'json'], deps); +await runCli( + [ + 'task', + 'capture', + '--title', + 'Release', + '--agent', + 'codex', + '--session', + 'session-1', + '--output', + 'json', + ], + deps, +); expect(application.findSimilar).toHaveBeenCalledWith({ title: 'Release', limit: 5 }); -expect(application.create).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-1', creator: { type: 'AGENT', name: 'codex' } })); -expect(json(stdout).warnings).toEqual([{ code: 'POSSIBLE_DUPLICATE', message: 'Similar tasks already exist.', candidates: [{ id: 'existing' }] }]); +expect(application.create).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1', creator: { type: 'AGENT', name: 'codex' } }), +); +expect(json(stdout).warnings).toEqual([ + { + code: 'POSSIBLE_DUPLICATE', + message: 'Similar tasks already exist.', + candidates: [{ id: 'existing' }], + }, +]); ``` - [ ] **Step 2: Run focused tests and confirm they fail for the unimplemented dispatch paths.** @@ -87,8 +128,17 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` - [ ] **Step 3: Implement handlers that call the corresponding application method exactly once and serialize MCP-parity payloads.** ```ts -const candidates = application.findSimilar({ title: command.title, ...(command.workspace === undefined ? {} : { workspace: command.workspace }), limit: command.limit }); -return success({ candidates: candidates.map((task) => ({ task: toTaskMcpDto(task), matchReason: matchReason(task, command.title) })) }); +const candidates = application.findSimilar({ + title: command.title, + ...(command.workspace === undefined ? {} : { workspace: command.workspace }), + limit: command.limit, +}); +return success({ + candidates: candidates.map((task) => ({ + task: toTaskMcpDto(task), + matchReason: matchReason(task, command.title), + })), +}); ``` - [ ] **Step 4: Re-run focused tests.** @@ -98,10 +148,12 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` ### Task 3: Mutation command handlers **Files:** + - Create: `src/interfaces/cli/commands/task-edit.ts`, `src/interfaces/cli/commands/task-triage.ts`, `src/interfaces/cli/commands/task-start.ts`, `src/interfaces/cli/commands/task-complete.ts`, `src/interfaces/cli/commands/task-archive.ts` - Modify: `src/interfaces/cli/run-cli.ts`, `tests/unit/interfaces/cli/run-cli.test.ts` **Interfaces:** + - Consumes: `TaskApplication.edit/moveToInbox/activate/moveToBacklog/start/complete/archive` and MCP change-metadata mappers. - Produces: mutation payload `{ task, change }` with exactly the #21 `EDITED`, `TRIAGED`, lifecycle, and `NO_CHANGE` shapes. @@ -110,7 +162,9 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` ```ts await runCli(['task', 'edit', 'id', '--clear-description', '--output', 'json'], deps); expect(application.edit).toHaveBeenCalledWith({ id: 'id', description: null }); -await expect(runCli(['task', 'triage', 'id', '--to', 'DONE', '--output', 'json'], deps)).resolves.toBe(2); +await expect( + runCli(['task', 'triage', 'id', '--to', 'DONE', '--output', 'json'], deps), +).resolves.toBe(2); expect(json(stdout).error.code).toBe('VALIDATION_ERROR'); ``` @@ -121,8 +175,16 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` - [ ] **Step 3: Implement narrow mutation handlers and reuse existing metadata mappers without generic status mutation.** ```ts -const mutation = command.target === 'INBOX' ? application.moveToInbox({ id: command.id }) : command.target === 'ACTIVE' ? application.activate({ id: command.id }) : application.moveToBacklog({ id: command.id }); -return success({ task: toTaskMcpDto(mutation.task), change: triageChange(mutation.before, mutation.task) }); +const mutation = + command.target === 'INBOX' + ? application.moveToInbox({ id: command.id }) + : command.target === 'ACTIVE' + ? application.activate({ id: command.id }) + : application.moveToBacklog({ id: command.id }); +return success({ + task: toTaskMcpDto(mutation.task), + change: triageChange(mutation.before, mutation.task), +}); ``` - [ ] **Step 4: Re-run focused tests and verify all stable exit categories.** @@ -132,17 +194,22 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` ### Task 4: Executable packaging, process integration, parity, and documentation **Files:** + - Create: `src/interfaces/cli/main.ts`, `tests/integration/cli.test.ts`, `tests/integration/mcp-cli-parity.test.ts` - Modify: `package.json`, `tsup.config.ts`, `tests/unit/scripts/validate-repository-assets.test.ts`, `scripts/validate-repository-assets.ts`, `docs/cli-reference.md`, `README.md` **Interfaces:** + - Consumes: `runCli`, `createTaskRuntime`, Node process argv/stdout/stderr, built `dist/cli/main.js`, and existing MCP fixture envelopes. - Produces: a `relay` bin entry and built CLI executable that operates from an arbitrary CWD with `RELAY_DB_PATH`. - [ ] **Step 1: Add failing built-process and MCP/CLI parity tests.** ```ts -const result = spawnSync(process.execPath, [builtCliPath, 'task', 'list', '--output', 'json'], { cwd: launchDir, env: { ...process.env, RELAY_DB_PATH: databasePath } }); +const result = spawnSync(process.execPath, [builtCliPath, 'task', 'list', '--output', 'json'], { + cwd: launchDir, + env: { ...process.env, RELAY_DB_PATH: databasePath }, +}); expect(result.status).toBe(0); expect(JSON.parse(result.stdout.toString())).toMatchObject({ schemaVersion: 1, ok: true }); expect(result.stderr.toString()).toBe(''); @@ -155,8 +222,13 @@ Run: `pnpm test -- tests/integration/cli.test.ts tests/integration/mcp-cli-parit - [ ] **Step 3: Add `main.ts`, the `relay` bin/build entries, asset validation, CLI reference, and README invocation guidance.** ```ts -void runCli(process.argv.slice(2), { createRuntime: createTaskRuntime, stdout: process.stdout, stderr: process.stderr }) - .then((exitCode) => { process.exitCode = exitCode; }); +void runCli(process.argv.slice(2), { + createRuntime: createTaskRuntime, + stdout: process.stdout, + stderr: process.stderr, +}).then((exitCode) => { + process.exitCode = exitCode; +}); ``` - [ ] **Step 4: Run the focused unit/integration suite, then the full issue verification gate.** diff --git a/src/interfaces/cli/output/cli-errors.ts b/src/interfaces/cli/output/cli-errors.ts index 8f028d9..dbee085 100644 --- a/src/interfaces/cli/output/cli-errors.ts +++ b/src/interfaces/cli/output/cli-errors.ts @@ -1,16 +1,41 @@ import { ZodError } from 'zod'; -import { InvalidTaskRequestError, TaskNotFoundError, TaskPersistenceError } from '../../../application/tasks/task-application-errors.js'; -import { TaskArchivedError, TaskDomainError, TaskTransitionError } from '../../../domain/task/task-errors.js'; +import { + InvalidTaskRequestError, + TaskNotFoundError, + TaskPersistenceError, +} from '../../../application/tasks/task-application-errors.js'; +import { + TaskArchivedError, + TaskDomainError, + TaskTransitionError, +} from '../../../domain/task/task-errors.js'; -export interface CliMappedError { readonly code: string; readonly message: string; readonly exitCode: number; } +export interface CliMappedError { + readonly code: string; + readonly message: string; + readonly exitCode: number; +} export class CliUsageError extends Error {} export function toCliError(error: unknown): CliMappedError { - if (error instanceof TaskArchivedError) return { code: 'ARCHIVED_TASK', message: 'The task is archived.', exitCode: 4 }; - if (error instanceof TaskTransitionError) return { code: 'CONFLICT', message: 'Task lifecycle transition is not allowed.', exitCode: 4 }; - if (error instanceof TaskNotFoundError) return { code: 'NOT_FOUND', message: 'Task was not found.', exitCode: 3 }; - if (error instanceof TaskPersistenceError) return { code: 'STORAGE_ERROR', message: 'Task storage operation failed.', exitCode: 5 }; - if (error instanceof CliUsageError || error instanceof ZodError || error instanceof InvalidTaskRequestError || error instanceof TaskDomainError) - return { code: 'VALIDATION_ERROR', message: error instanceof CliUsageError ? error.message : 'Request validation failed.', exitCode: 2 }; + if (error instanceof TaskArchivedError) + return { code: 'ARCHIVED_TASK', message: 'The task is archived.', exitCode: 4 }; + if (error instanceof TaskTransitionError) + return { code: 'CONFLICT', message: 'Task lifecycle transition is not allowed.', exitCode: 4 }; + if (error instanceof TaskNotFoundError) + return { code: 'NOT_FOUND', message: 'Task was not found.', exitCode: 3 }; + if (error instanceof TaskPersistenceError) + return { code: 'STORAGE_ERROR', message: 'Task storage operation failed.', exitCode: 5 }; + if ( + error instanceof CliUsageError || + error instanceof ZodError || + error instanceof InvalidTaskRequestError || + error instanceof TaskDomainError + ) + return { + code: 'VALIDATION_ERROR', + message: error instanceof CliUsageError ? error.message : 'Request validation failed.', + exitCode: 2, + }; return { code: 'INTERNAL_ERROR', message: 'An unexpected internal error occurred.', exitCode: 1 }; } diff --git a/src/interfaces/cli/run-cli.ts b/src/interfaces/cli/run-cli.ts index 21ea80c..6d215b7 100644 --- a/src/interfaces/cli/run-cli.ts +++ b/src/interfaces/cli/run-cli.ts @@ -1,4 +1,8 @@ -import type { CreateTaskInput, EditTaskInput, TaskApplication } from '../../application/tasks/task-application.js'; +import type { + CreateTaskInput, + EditTaskInput, + TaskApplication, +} from '../../application/tasks/task-application.js'; import type { TaskPriority } from '../../domain/task/task-priority.js'; import { TASK_STATUSES, type TaskStatus } from '../../domain/task/task-status.js'; import type { TaskRuntime } from '../shared/create-task-runtime.js'; @@ -8,10 +12,22 @@ import { CliUsageError, toCliError } from './output/cli-errors.js'; import { cliFailure, cliSuccess } from './output/cli-result.js'; type Writer = { write(text: string): unknown }; -export interface CliDependencies { readonly createRuntime: () => TaskRuntime; readonly stdout: Writer; readonly stderr: Writer; } -type Command = { readonly group: 'task' | 'session'; readonly action: string; readonly id?: string; readonly options: ReadonlyMap; }; +export interface CliDependencies { + readonly createRuntime: () => TaskRuntime; + readonly stdout: Writer; + readonly stderr: Writer; +} +type Command = { + readonly group: 'task' | 'session'; + readonly action: string; + readonly id?: string; + readonly options: ReadonlyMap; +}; -export async function runCli(argv: readonly string[], dependencies: CliDependencies): Promise { +export async function runCli( + argv: readonly string[], + dependencies: CliDependencies, +): Promise { try { const command = parse(argv); const runtime = dependencies.createRuntime(); @@ -20,49 +36,206 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc const { warnings = [], ...data } = result; write(dependencies.stdout, cliSuccess(data, warnings as readonly unknown[])); return 0; + } catch (error) { + return writeError(error, dependencies); + } finally { + runtime.close(); } - catch (error) { return writeError(error, dependencies); } - finally { runtime.close(); } - } catch (error) { return writeError(error, dependencies); } + } catch (error) { + return writeError(error, dependencies); + } } function writeError(error: unknown, { stdout, stderr }: CliDependencies): number { - const mapped = toCliError(error); write(stdout, cliFailure(mapped.code, mapped.message)); stderr.write(`${mapped.message}\n`); return mapped.exitCode; + const mapped = toCliError(error); + write(stdout, cliFailure(mapped.code, mapped.message)); + stderr.write(`${mapped.message}\n`); + return mapped.exitCode; +} +function write(writer: Writer, value: unknown): void { + writer.write(`${JSON.stringify(value)}\n`); } -function write(writer: Writer, value: unknown): void { writer.write(`${JSON.stringify(value)}\n`); } function parse(argv: readonly string[]): Command { const [group, action, ...rest] = argv; - if ((group !== 'task' && group !== 'session') || !action) throw new CliUsageError('Unknown or missing command.'); - const needsId = group === 'task' && ['get', 'edit', 'triage', 'start', 'complete', 'archive'].includes(action); + if ((group !== 'task' && group !== 'session') || !action) + throw new CliUsageError('Unknown or missing command.'); + const needsId = + group === 'task' && ['get', 'edit', 'triage', 'start', 'complete', 'archive'].includes(action); const id = needsId ? rest.shift() : undefined; if (needsId && (!id || id.startsWith('--'))) throw new CliUsageError('A task id is required.'); const options = new Map(); for (let index = 0; index < rest.length; index += 1) { - const token = rest[index]; if (!token?.startsWith('--')) throw new CliUsageError(`Unexpected argument: ${token}`); - const key = token.slice(2); const flags = new Set(['clear-description', 'clear-priority', 'clear-workspace', 'clear-source-context']); + const token = rest[index]; + if (!token?.startsWith('--')) throw new CliUsageError(`Unexpected argument: ${token}`); + const key = token.slice(2); + const flags = new Set([ + 'clear-description', + 'clear-priority', + 'clear-workspace', + 'clear-source-context', + ]); const value = flags.has(key) ? 'true' : rest[++index]; - if (value === undefined || value.startsWith('--')) throw new CliUsageError(`Missing value for --${key}.`); - const existing = options.get(key) ?? []; if (key !== 'status' && existing.length) throw new CliUsageError(`Option --${key} may be supplied only once.`); options.set(key, [...existing, value]); + if (value === undefined || value.startsWith('--')) + throw new CliUsageError(`Missing value for --${key}.`); + const existing = options.get(key) ?? []; + if (key !== 'status' && existing.length) + throw new CliUsageError(`Option --${key} may be supplied only once.`); + options.set(key, [...existing, value]); } - if (option(options, 'output', false) !== 'json') throw new CliUsageError('All supported commands require --output json.'); + if (option(options, 'output', false) !== 'json') + throw new CliUsageError('All supported commands require --output json.'); return { group, action, ...(id === undefined ? {} : { id }), options }; } -function option(options: ReadonlyMap, key: string, required = false): string | undefined { const value = options.get(key)?.[0]; if (required && value === undefined) throw new CliUsageError(`Missing required option --${key}.`); return value; } -function numberOption(options: ReadonlyMap, key: string, maximum: number, fallback: number): number { const raw = option(options, key); if (raw === undefined) return fallback; const value = Number(raw); if (!Number.isInteger(value) || value < 1 || value > maximum) throw new CliUsageError(`--${key} must be an integer from 1 through ${maximum}.`); return value; } -function optionalFields(command: Command): Omit { const o = command.options; const description = option(o, 'description'); const priority = option(o, 'priority'); const workspace = option(o, 'workspace'); const sourceContext = option(o, 'source-context'); return { ...(description === undefined ? {} : { description }), ...(priority === undefined ? {} : { priority: priority as TaskPriority }), ...(workspace === undefined ? {} : { workspace }), ...(sourceContext === undefined ? {} : { sourceContext }) }; } -function workspaceOption(options: ReadonlyMap) { const workspace = option(options, 'workspace'); return workspace === undefined ? {} : { workspace }; } +function option( + options: ReadonlyMap, + key: string, + required = false, +): string | undefined { + const value = options.get(key)?.[0]; + if (required && value === undefined) throw new CliUsageError(`Missing required option --${key}.`); + return value; +} +function numberOption( + options: ReadonlyMap, + key: string, + maximum: number, + fallback: number, +): number { + const raw = option(options, key); + if (raw === undefined) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > maximum) + throw new CliUsageError(`--${key} must be an integer from 1 through ${maximum}.`); + return value; +} +function optionalFields( + command: Command, +): Omit { + const o = command.options; + const description = option(o, 'description'); + const priority = option(o, 'priority'); + const workspace = option(o, 'workspace'); + const sourceContext = option(o, 'source-context'); + return { + ...(description === undefined ? {} : { description }), + ...(priority === undefined ? {} : { priority: priority as TaskPriority }), + ...(workspace === undefined ? {} : { workspace }), + ...(sourceContext === undefined ? {} : { sourceContext }), + }; +} +function workspaceOption(options: ReadonlyMap) { + const workspace = option(options, 'workspace'); + return workspace === undefined ? {} : { workspace }; +} function execute(command: Command, application: TaskApplication): Record { const o = command.options; - if (command.group === 'session' && command.action === 'captures') { const sessionId = option(o, 'session', true)!; const tasks = application.listSessionCaptures({ sessionId, limit: numberOption(o, 'limit', 100, 100) }); return { sessionId, tasks: tasks.map(toTaskMcpDto), count: tasks.length }; } + if (command.group === 'session' && command.action === 'captures') { + const sessionId = option(o, 'session', true)!; + const tasks = application.listSessionCaptures({ + sessionId, + limit: numberOption(o, 'limit', 100, 100), + }); + return { sessionId, tasks: tasks.map(toTaskMcpDto), count: tasks.length }; + } if (command.group !== 'task') throw new CliUsageError('Unknown command.'); if (command.action === 'get') return { task: toTaskMcpDto(application.get({ id: command.id! })) }; - if (command.action === 'list') { const statuses = (o.get('status') ?? [...TASK_STATUSES]) as readonly TaskStatus[]; const tasks = application.list({ statuses, limit: numberOption(o, 'limit', 100, 100), ...workspaceOption(o) }); return { tasks: tasks.map(toTaskMcpDto), count: tasks.length }; } - if (command.action === 'find-similar') { const title = option(o, 'title', true)!; const candidates = application.findSimilar({ title, limit: numberOption(o, 'limit', 5, 5), ...workspaceOption(o) }); return { candidates: candidates.map((task) => ({ task: toTaskMcpDto(task), matchReason: matchReason(task, title) })) }; } - if (command.action === 'capture') { const title = option(o, 'title', true)!; const matches = application.findSimilar({ title, limit: 5, ...workspaceOption(o) }); const task = application.create({ title, ...optionalFields(command), sessionId: option(o, 'session', true)!, creator: { type: 'AGENT', name: option(o, 'agent', true)! } }); return { task: toTaskMcpDto(task), change: { action: 'CREATED' }, warnings: matches.length ? [{ code: 'POSSIBLE_DUPLICATE', message: 'Similar tasks already exist.', candidates: matches.map(({ id }) => ({ id })) }] : [] }; } - if (command.action === 'edit') { const fields = optionalFields(command); const clears = { ...(o.has('clear-description') ? { description: null } : {}), ...(o.has('clear-priority') ? { priority: null } : {}), ...(o.has('clear-workspace') ? { workspace: null } : {}), ...(o.has('clear-source-context') ? { sourceContext: null } : {}) }; const title = option(o, 'title'); if (!Object.keys(fields).length && !Object.keys(clears).length && title === undefined) throw new CliUsageError('At least one editable task field is required.'); const mutation = application.edit({ id: command.id!, ...(title === undefined ? {} : { title }), ...fields, ...clears } as EditTaskInput); return { task: toTaskMcpDto(mutation.task), change: editChange(mutation.before, mutation.task) }; } - if (command.action === 'triage') { const target = option(o, 'to', true)!; const mutation = target === 'INBOX' ? application.moveToInbox({ id: command.id! }) : target === 'ACTIVE' ? application.activate({ id: command.id! }) : target === 'BACKLOG' ? application.moveToBacklog({ id: command.id! }) : (() => { throw new CliUsageError('--to must be INBOX, ACTIVE, or BACKLOG.'); })(); return { task: toTaskMcpDto(mutation.task), change: triageChange(mutation.before, mutation.task) }; } - const methods = { start: ['start', 'STARTED'], complete: ['complete', 'COMPLETED'], archive: ['archive', 'ARCHIVED'] } as const; - const method = methods[command.action as keyof typeof methods]; if (!method) throw new CliUsageError('Unknown command.'); const mutation = application[method[0]]({ id: command.id! }); return { task: toTaskMcpDto(mutation.task), change: lifecycleChange(mutation.before, mutation.task, method[1]) }; + if (command.action === 'list') { + const statuses = (o.get('status') ?? [...TASK_STATUSES]) as readonly TaskStatus[]; + const tasks = application.list({ + statuses, + limit: numberOption(o, 'limit', 100, 100), + ...workspaceOption(o), + }); + return { tasks: tasks.map(toTaskMcpDto), count: tasks.length }; + } + if (command.action === 'find-similar') { + const title = option(o, 'title', true)!; + const candidates = application.findSimilar({ + title, + limit: numberOption(o, 'limit', 5, 5), + ...workspaceOption(o), + }); + return { + candidates: candidates.map((task) => ({ + task: toTaskMcpDto(task), + matchReason: matchReason(task, title), + })), + }; + } + if (command.action === 'capture') { + const title = option(o, 'title', true)!; + const matches = application.findSimilar({ title, limit: 5, ...workspaceOption(o) }); + const task = application.create({ + title, + ...optionalFields(command), + sessionId: option(o, 'session', true)!, + creator: { type: 'AGENT', name: option(o, 'agent', true)! }, + }); + return { + task: toTaskMcpDto(task), + change: { action: 'CREATED' }, + warnings: matches.length + ? [ + { + code: 'POSSIBLE_DUPLICATE', + message: 'Similar tasks already exist.', + candidates: matches.map(({ id }) => ({ id })), + }, + ] + : [], + }; + } + if (command.action === 'edit') { + const fields = optionalFields(command); + const clears = { + ...(o.has('clear-description') ? { description: null } : {}), + ...(o.has('clear-priority') ? { priority: null } : {}), + ...(o.has('clear-workspace') ? { workspace: null } : {}), + ...(o.has('clear-source-context') ? { sourceContext: null } : {}), + }; + const title = option(o, 'title'); + if (!Object.keys(fields).length && !Object.keys(clears).length && title === undefined) + throw new CliUsageError('At least one editable task field is required.'); + const mutation = application.edit({ + id: command.id!, + ...(title === undefined ? {} : { title }), + ...fields, + ...clears, + } as EditTaskInput); + return { + task: toTaskMcpDto(mutation.task), + change: editChange(mutation.before, mutation.task), + }; + } + if (command.action === 'triage') { + const target = option(o, 'to', true)!; + const mutation = + target === 'INBOX' + ? application.moveToInbox({ id: command.id! }) + : target === 'ACTIVE' + ? application.activate({ id: command.id! }) + : target === 'BACKLOG' + ? application.moveToBacklog({ id: command.id! }) + : (() => { + throw new CliUsageError('--to must be INBOX, ACTIVE, or BACKLOG.'); + })(); + return { + task: toTaskMcpDto(mutation.task), + change: triageChange(mutation.before, mutation.task), + }; + } + const methods = { + start: ['start', 'STARTED'], + complete: ['complete', 'COMPLETED'], + archive: ['archive', 'ARCHIVED'], + } as const; + const method = methods[command.action as keyof typeof methods]; + if (!method) throw new CliUsageError('Unknown command.'); + const mutation = application[method[0]]({ id: command.id! }); + return { + task: toTaskMcpDto(mutation.task), + change: lifecycleChange(mutation.before, mutation.task, method[1]), + }; } diff --git a/tests/unit/interfaces/cli/run-cli.test.ts b/tests/unit/interfaces/cli/run-cli.test.ts index 797be26..f7d9f58 100644 --- a/tests/unit/interfaces/cli/run-cli.test.ts +++ b/tests/unit/interfaces/cli/run-cli.test.ts @@ -60,11 +60,26 @@ describe('runCli', () => { close: vi.fn(), }; await runCli( - ['task', 'capture', '--title', 'Task', '--agent', 'codex', '--session', 'session-1', '--output', 'json'], + [ + 'task', + 'capture', + '--title', + 'Task', + '--agent', + 'codex', + '--session', + 'session-1', + '--output', + 'json', + ], { createRuntime: () => runtime, stdout, stderr: { write: vi.fn() } }, ); expect(JSON.parse(stdout.write.mock.calls[0]?.[0] as string).warnings).toEqual([ - { code: 'POSSIBLE_DUPLICATE', message: 'Similar tasks already exist.', candidates: [{ id: 'existing' }] }, + { + code: 'POSSIBLE_DUPLICATE', + message: 'Similar tasks already exist.', + candidates: [{ id: 'existing' }], + }, ]); }); }); From beb9aa94b29a8569e0ade913ee94b8a9e43b8028 Mon Sep 17 00:00:00 2001 From: Krishnamurti Pandaram Date: Mon, 27 Jul 2026 22:25:49 +0530 Subject: [PATCH 3/4] Complete CLI review remediation --- README.md | 12 +- docs/cli-reference.md | 142 ++++--- .../tasks/2026-07-27-pr-31-review-tracker.md | 109 ++++++ scripts/validate-repository-assets.ts | 25 ++ src/interfaces/cli/cli-command.ts | 67 ++++ src/interfaces/cli/commands/command-result.ts | 4 + .../cli/commands/session-captures.ts | 17 + src/interfaces/cli/commands/task-capture.ts | 37 ++ src/interfaces/cli/commands/task-edit.ts | 15 + .../cli/commands/task-find-similar.ts | 23 ++ src/interfaces/cli/commands/task-get.ts | 11 + src/interfaces/cli/commands/task-lifecycle.ts | 25 ++ src/interfaces/cli/commands/task-list.ts | 16 + src/interfaces/cli/commands/task-triage.ts | 20 + src/interfaces/cli/execute-cli-command.ts | 37 ++ src/interfaces/cli/output/cli-errors.ts | 12 +- src/interfaces/cli/parse-cli.ts | 346 ++++++++++++++++++ src/interfaces/cli/run-cli.ts | 263 +++---------- .../mapping => contracts}/change-metadata.ts | 2 +- src/interfaces/contracts/task-dto.ts | 27 ++ src/interfaces/http/task-dto.ts | 24 +- src/interfaces/mcp/mapping/task-mcp-dto.ts | 9 - .../mcp/tools/register-read-tools.ts | 10 +- src/interfaces/mcp/tools/task-archive.ts | 6 +- src/interfaces/mcp/tools/task-capture.ts | 4 +- src/interfaces/mcp/tools/task-complete.ts | 6 +- src/interfaces/mcp/tools/task-edit.ts | 6 +- src/interfaces/mcp/tools/task-start.ts | 6 +- src/interfaces/mcp/tools/task-triage.ts | 6 +- tests/integration/cli.test.ts | 183 +++++++++ tests/integration/mcp-cli-parity.test.ts | 236 ++++++++++++ .../unit/interfaces/cli/architecture.test.ts | 23 ++ tests/unit/interfaces/cli/cli-errors.test.ts | 50 +++ .../interfaces/cli/command-handlers.test.ts | 132 +++++++ tests/unit/interfaces/cli/parse-cli.test.ts | 158 ++++++++ tests/unit/interfaces/cli/run-cli.test.ts | 80 ++++ .../validate-repository-assets.test.ts | 26 +- 37 files changed, 1856 insertions(+), 319 deletions(-) create mode 100644 docs/superpowers/tasks/2026-07-27-pr-31-review-tracker.md create mode 100644 src/interfaces/cli/cli-command.ts create mode 100644 src/interfaces/cli/commands/command-result.ts create mode 100644 src/interfaces/cli/commands/session-captures.ts create mode 100644 src/interfaces/cli/commands/task-capture.ts create mode 100644 src/interfaces/cli/commands/task-edit.ts create mode 100644 src/interfaces/cli/commands/task-find-similar.ts create mode 100644 src/interfaces/cli/commands/task-get.ts create mode 100644 src/interfaces/cli/commands/task-lifecycle.ts create mode 100644 src/interfaces/cli/commands/task-list.ts create mode 100644 src/interfaces/cli/commands/task-triage.ts create mode 100644 src/interfaces/cli/execute-cli-command.ts create mode 100644 src/interfaces/cli/parse-cli.ts rename src/interfaces/{mcp/mapping => contracts}/change-metadata.ts (93%) create mode 100644 src/interfaces/contracts/task-dto.ts delete mode 100644 src/interfaces/mcp/mapping/task-mcp-dto.ts create mode 100644 tests/integration/cli.test.ts create mode 100644 tests/integration/mcp-cli-parity.test.ts create mode 100644 tests/unit/interfaces/cli/architecture.test.ts create mode 100644 tests/unit/interfaces/cli/cli-errors.test.ts create mode 100644 tests/unit/interfaces/cli/command-handlers.test.ts create mode 100644 tests/unit/interfaces/cli/parse-cli.test.ts diff --git a/README.md b/README.md index 2b588e9..8d84371 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Relay -The approved agent-integration contract is documented in the [decision record](docs/decisions/0002-agent-integration-contracts.md), [MCP tool reference](docs/mcp-tools.md), [CLI reference](docs/cli-reference.md), and [session semantics](docs/session-semantics.md). The production MCP task tools are shipped; CLI task handlers remain downstream work. +The approved agent-integration contract is documented in the [decision record](docs/decisions/0002-agent-integration-contracts.md), [MCP tool reference](docs/mcp-tools.md), [CLI reference](docs/cli-reference.md), and [session semantics](docs/session-semantics.md). The production MCP task tools and source-checkout CLI are shipped. Relay is a local task sidecar for human–AI workflows. The current MVP is usable through its local web UI and through five safe local stdio MCP task tools. @@ -43,6 +43,15 @@ To run the MCP server after building: node dist/mcp/main.js ``` +To use the source-checkout CLI from any working directory: + +```bash +pnpm build:node +RELAY_DB_PATH=/tmp/relay.db node /absolute/path/to/relay/dist/cli/main.js task list --output json +``` + +The CLI calls `TaskApplication` directly; it does not start HTTP or MCP processes. Its JSON envelope is authoritative: stdout contains one JSON document and newline, success writes no stderr, and failures also print one human-readable diagnostic to stderr. See the [CLI reference](docs/cli-reference.md) for all commands and stable exit codes. + It exposes five task tools—`task_capture`, `task_list`, `task_get`, `task_find_similar`, and `session_captures_list`—plus the separate `relay_health` tool. MCP task results use structured schema-versioned payloads; capture records AGENT provenance and reports possible duplicates as advisory warnings. ## Database and safe development data @@ -96,6 +105,7 @@ src/ interfaces/ http/ # Loopback HTTP adapter and compiled UI serving mcp/ # MCP health and production task-tool adapter + cli/ # Source-checkout JSON CLI adapter web/ # React UI that calls the HTTP API only ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b897627..c0d5bea 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,69 +1,123 @@ # Relay CLI Contract Reference -Issue #19 reserves a deterministic, versioned CLI contract. Production command handlers are implemented later; the stable executable surface is one `relay` command: - -```text -relay mcp -relay ui -relay doctor -relay task ... -relay session ... +The source-checkout CLI is a built Node entry point at `dist/cli/main.js`. It supports ten task/session commands and always uses JSON mode. + +## Build and invoke + +From the repository root: + +```bash +pnpm build:node ``` -`relay-mcp` may remain as a compatibility entry point, but new integrations target `relay mcp`. +Invoke the built file with an absolute path from any working directory: -## Source-checkout invocation +```bash +RELAY_DB_PATH=/tmp/relay.db node /absolute/path/to/relay/dist/cli/main.js task list --output json +``` -Build the project, then invoke `node dist/cli/main.js` from any directory. Set `RELAY_DB_PATH` when an explicit database location is needed: +On Windows PowerShell: -```text -RELAY_DB_PATH=/tmp/relay.db node /path/to/relay/dist/cli/main.js task list --output json +```powershell +$env:RELAY_DB_PATH = 'C:\temp\relay.db' +node C:\absolute\path\to\relay\dist\cli\main.js task list --output json ``` -The task and session commands below call `TaskApplication` directly. They never start HTTP or MCP processes. +`RELAY_DB_PATH` selects the SQLite database. If it is blank or unset, Relay uses the platform default from `src/database/database-config.ts`. The working directory does not affect storage or migration lookup. + +The CLI calls `TaskApplication` directly. It never starts an HTTP server or MCP process and does not access SQLite from the adapter. ## JSON protocol -Every agent-facing command accepts `--output json`. JSON mode writes one JSON document followed by a newline to stdout; diagnostics are written to stderr. No caller needs to parse decorative output. +Every command requires the exact option `--output json`. Stdout contains exactly one JSON document followed by one newline. Success writes no stderr; failures write one public JSON failure envelope to stdout and one human-readable diagnostic to stderr. Stack traces, SQL, secrets, and local paths are not part of the public error contract. + +Success envelope: ```json { "schemaVersion": 1, "ok": true, "data": {}, "warnings": [] } ``` -Error details are optional and never expose SQL, stacks, secrets, or local paths. +Failure envelope: ```json { "schemaVersion": 1, "ok": false, - "error": { "code": "VALIDATION_ERROR", "message": "sessionId has an invalid format" } + "error": { "code": "VALIDATION_ERROR", "message": "A task id is required." } } ``` -| Exit code | Meaning | Error codes | -| --------- | ------------------------------------ | --------------------------- | -| 0 | Success, warnings, or approved no-op | — | -| 1 | Unexpected internal failure | `INTERNAL_ERROR` | -| 2 | Usage or validation failure | `VALIDATION_ERROR` | -| 3 | Task absent | `NOT_FOUND` | -| 4 | Invalid lifecycle operation | `CONFLICT`, `ARCHIVED_TASK` | -| 5 | Storage failure | `STORAGE_ERROR` | - -## Commands - -| Command | Required arguments | Optional arguments | Result | -| -------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `relay task capture` | `--title`, `--agent`, `--session` | `--description`, `--priority`, `--workspace`, `--source-context`, `--output json` | `{ task, change: { action: "CREATED" } }` and optional warnings | -| `relay task list` | — | repeatable `--status`, `--workspace`, `--limit 1..100`, `--output json` | `{ tasks, count }` | -| `relay task get ` | ID | `--output json` | `{ task }` | -| `relay task find-similar` | `--title` | `--workspace`, `--limit 1..5`, `--output json` | `{ candidates }` | -| `relay session captures` | `--session` | `--limit 1..100`, `--output json` | `{ sessionId, tasks, count }` | -| `relay task edit ` | ID and an editable field | clear flags and `--output json` | `{ task, change }` | -| `relay task triage ` | ID and `--to INBOX`, `ACTIVE`, or `BACKLOG` | `--output json` | `{ task, change }` | -| `relay task start ` | ID | `--output json` | `{ task, change }` | -| `relay task complete ` | ID | `--output json` | `{ task, change }` | -| `relay task archive ` | ID | `--output json` | `{ task, change }` | - -Editing accepts existing editable fields only. Clear nullable fields with explicit flags such as `--clear-description`; empty strings and MCP `null` values are rejected rather than treated as clearing requests. A value and its corresponding clear flag cannot be supplied together. `task triage` excludes `IN_PROGRESS`, `DONE`, and `ARCHIVED`, because those transitions have dedicated intent-specific commands. - -CLI commands call application services and reuse `src/database/database-config.ts`; they never access SQLite directly. Database path precedence is explicit command/injected path, non-blank `RELAY_DB_PATH`, then the platform default. The working directory is never production storage configuration. +Duplicate detection during capture is advisory. A duplicate warning is included in `warnings`, but the command still succeeds with exit code `0`. + +## Commands and options + +Options shown as required must appear exactly once. Options shown as repeatable may appear more than once. Unknown options and positional arguments are usage errors. + +### `task capture` + +Required: `--title TEXT`, `--agent NAME`, `--session ID`, `--output json`. + +Optional: `--description TEXT`, `--priority LOW|NORMAL|HIGH`, `--workspace NAME`, `--source-context TEXT`. + +Creates an `AGENT` task, performs an advisory similar-task lookup first, and preserves agent, session, workspace, and source context. + +### `task list` + +Required: `--output json`. + +Optional: repeatable `--status INBOX|ACTIVE|IN_PROGRESS|BACKLOG|DONE|ARCHIVED`, `--workspace NAME`, and `--limit INTEGER` from `1` through `100`. + +Without `--status`, all task statuses are selected. The default limit is `100`. + +### `task get ID` + +Required: a task `ID` and `--output json`. + +No other options are accepted. + +### `task find-similar` + +Required: `--title TEXT`, `--output json`. + +Optional: `--workspace NAME` and `--limit INTEGER` from `1` through `5`. The default limit is `5`. + +### `task edit ID` + +Required: a task `ID`, at least one edit operation, and `--output json`. + +Editable values: `--title TEXT`, `--description TEXT`, `--priority LOW|NORMAL|HIGH`, `--workspace NAME`, and `--source-context TEXT`. + +Clear flags: `--clear-description`, `--clear-priority`, `--clear-workspace`, and `--clear-source-context`. A value and its matching clear flag cannot be supplied together. Empty strings are rejected rather than interpreted as clears. A no-op edit is valid when an edit operation is supplied and returns `change.action` `NO_CHANGE`. + +### `task triage ID` + +Required: a task `ID`, `--to INBOX|ACTIVE|BACKLOG`, and `--output json`. + +No other options are accepted. Triage uses the corresponding focused application mutation. + +### `task start ID`, `task complete ID`, and `task archive ID` + +Required: a task `ID` and `--output json`. + +No other options are accepted. Each command calls its matching lifecycle method and returns `STARTED`, `COMPLETED`, or `ARCHIVED` change metadata, or `NO_CHANGE` for an idempotent operation. + +### `session captures` + +Required: `--session ID` and `--output json`. + +Optional: `--limit INTEGER` from `1` through `100`; the default is `100`. + +Returns captured AGENT tasks for the session. + +## Exit codes + +| Exit code | Meaning | Error code | +| --------: | ------------------------------------ | --------------------------- | +| `0` | Success, warnings, or approved no-op | — | +| `1` | Unexpected internal failure | `INTERNAL_ERROR` | +| `2` | Usage or validation failure | `VALIDATION_ERROR` | +| `3` | Task was not found | `NOT_FOUND` | +| `4` | Conflict or archived-task operation | `CONFLICT`, `ARCHIVED_TASK` | +| `5` | Storage or persistence failure | `STORAGE_ERROR` | + +The CLI intentionally excludes HTTP calls, MCP process spawning, direct SQLite access, publication, installers, setup/doctor/update commands, shell completion, TUI work, and vendor-specific assets. diff --git a/docs/superpowers/tasks/2026-07-27-pr-31-review-tracker.md b/docs/superpowers/tasks/2026-07-27-pr-31-review-tracker.md new file mode 100644 index 0000000..088d415 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-27-pr-31-review-tracker.md @@ -0,0 +1,109 @@ +# PR #31 Review Task Tracker + +Source: [Luna remediation plan](https://github.com/krishna916/relay/pull/31#issuecomment-5093695935) and the unresolved cleanup thread. + +Scope: complete every actionable review task while preserving the issue #22 CLI contract, draft PR state, direct `TaskApplication` calls, and the review's out-of-scope boundaries. + +## Working rules + +- [x] Use test-first changes and keep each task independently verifiable. +- [x] Do not add HTTP calls, MCP process spawning, direct SQLite access, publication, installers, setup/doctor/update commands, shell completion, TUI work, or vendor-specific assets. +- [x] Do not post GitHub replies, resolve the review thread, or change draft state. + +## Task 1 - Strict parsed-command model + +- [x] Extract parsing into `src/interfaces/cli/parse-cli.ts` and define a discriminated union for all ten commands. +- [x] Validate IDs, required options, explicit allowlists, values, duplicates, enums, limits, edit conflicts, and JSON output before runtime creation. +- [x] Validate canonical task priorities and statuses without unsafe casts. +- [x] Ensure execution receives only validated typed commands and has no usage checks. +- [x] Add parser tests for valid invocations and the listed failure categories. +- [x] Confirm parser failures emit one envelope, one diagnostic, exit `2`, and do not create a runtime. + +## Task 2 - Focused command handlers + +- [x] Split execution into focused handlers under `src/interfaces/cli/commands/` with an exhaustive dispatcher. +- [x] Keep handlers free of persistence/runtime knowledge and stdout/stderr writes. +- [x] Preserve duplicate lookup-before-create, AGENT provenance, session/workspace/source context, warnings, and mutation metadata. +- [x] Add handler tests for reads, capture, nullable clears, no-op edits, duplicate candidates, filters, triage, and lifecycle operations. + +## Task 3 - Adapter-neutral shared mappings + +- [x] Move task DTO, match-reason, and change-metadata mappers to `src/interfaces/contracts/`. +- [x] Update MCP, HTTP, and CLI imports without duplicating implementations. +- [x] Add an architectural assertion that CLI files do not import from `interfaces/mcp`. +- [x] Preserve existing MCP behavior and tests. + +## Task 4 - Exactly one JSON envelope + +- [x] Separate parse, runtime creation, execution, cleanup, and final emission phases. +- [x] Defer output until execution and cleanup outcomes are captured and close runtime exactly once. +- [x] Preserve command errors over cleanup errors and map cleanup-only failures to one internal failure. +- [x] Keep stdout protocol-only and stderr diagnostic-only with no low-level details. +- [x] Add writer-spy tests for success/failure with successful/throwing cleanup, runtime creation failure, and parser failure, covering `run-cli.ts:44`. + +## Task 5 - Stable errors and exit codes + +- [x] Verify exit codes `0` through `5` against the canonical error hierarchy. +- [x] Avoid classifying unexpected errors as validation. +- [x] Assert deterministic public codes/messages, warning success behavior, one failure envelope, and success stderr silence. +- [x] Add representative tests for usage, invalid request, not found, archived, conflict, storage, unexpected, and duplicate-warning cases. + +## Task 6 - MCP/CLI parity integration + +- [x] Add `tests/integration/mcp-cli-parity.test.ts` with isolated deterministic fixtures. +- [x] Cover capture, duplicate warning, list, get, find-similar, session captures, edit, clear, no-op, all triage targets, start, complete, archive, not-found, and conflict. +- [x] Compare semantic task DTOs, warnings, and change metadata rather than transport wrappers. + +## Task 7 - Built-process CLI integration + +- [x] Add built-artifact tests invoking `dist/cli/main.js` with `process.execPath`. +- [x] Run from a non-repository CWD with an isolated `RELAY_DB_PATH`. +- [x] Verify persistence across processes, protocol output, exit codes, parser-before-storage behavior, and storage failure coverage. +- [x] Clean temporary directories/databases deterministically. + +## Task 8 - Asset/build validation + +- [x] Validate `relay -> ./dist/cli/main.js`, the CLI build entry, and the built artifact. +- [x] Extend repository asset validation for CLI source/build/bin/documentation consistency. +- [x] Update asset validation tests and preserve CWD-independent output. + +## Task 9 - CLI documentation + +- [x] Document source-checkout build, absolute-path invocation, `RELAY_DB_PATH`, all ten commands, every option, JSON-only mode, envelopes, exit codes, stdout/stderr, duplicate warnings, direct `TaskApplication` architecture, and out-of-scope behavior. +- [x] Remove stale planned/unsupported CLI claims from `README.md` and `docs/cli-reference.md`. + +## Task 10 - Full verification gate + +- [x] Run focused CLI unit tests, built CLI integration tests, and MCP/CLI parity tests. +- [x] Run formatting, lint, typecheck, coverage, build, asset validation, and `pnpm verify`. +- [x] Perform the final self-review checklist from the PR plan. +- [x] Record verification evidence and intentional deviations. + +## Verification log + +| Check | Result | Evidence | +| -------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Baseline | Pass | Focused baseline, typecheck, and asset validation passed before implementation. | +| Focused review tests | Pass | Parser 25, handlers 5, CLI errors 6, cleanup/output 6, architecture 1, asset validation 5, built CLI 2, parity 15. | +| Full suite | Pass | `pnpm test`: 32 files and 436 tests. | +| Coverage | Pass | `pnpm test:coverage`: 88.74% statements, 81.34% branches, 88.94% functions, 90.90% lines. | +| Quality/build | Pass | `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, `pnpm build`, and `pnpm validate:assets`. | +| Full gate | Pass | Escalated `pnpm verify` completed successfully; audit reported 1 low and 1 moderate vulnerability, below the high-severity failure threshold. | + +## Final self-review + +- [x] All ten commands are implemented. +- [x] Every command rejects unknown options. +- [x] All usage validation finishes before runtime creation. +- [x] CLI has no imports from `interfaces/mcp`. +- [x] Runtime is closed exactly once. +- [x] Cleanup failure cannot produce a second JSON document. +- [x] stdout contains only one JSON envelope and newline. +- [x] stderr contains no success output. +- [x] All six exit-code categories are tested. +- [x] Built CLI works from a non-repository CWD. +- [x] Separate CLI processes use the same `RELAY_DB_PATH` database. +- [x] MCP/CLI parity tests pass. +- [x] Asset validation covers the new executable. +- [x] `pnpm verify` passes. +- [x] No forbidden scope was added. diff --git a/scripts/validate-repository-assets.ts b/scripts/validate-repository-assets.ts index 56e0fbd..23a0268 100644 --- a/scripts/validate-repository-assets.ts +++ b/scripts/validate-repository-assets.ts @@ -131,10 +131,14 @@ export function validateRepositoryAssets(options: ValidateRepositoryAssetsOption 'tests/fixtures/contracts/transition-conflict-error.json', 'tests/fixtures/contracts/storage-error.json', 'tsconfig.base.json', + 'tsup.config.ts', 'src/application/health/get-health.ts', 'src/database/connection.ts', 'src/interfaces/mcp/create-mcp-server.ts', 'src/interfaces/http/create-http-server.ts', + 'src/interfaces/cli/main.ts', + 'src/interfaces/cli/run-cli.ts', + 'src/interfaces/cli/parse-cli.ts', 'src/interfaces/contracts/contract-version.ts', 'src/interfaces/contracts/error-contract.ts', 'src/interfaces/contracts/json-value-contract.ts', @@ -155,6 +159,10 @@ export function validateRepositoryAssets(options: ValidateRepositoryAssetsOption bin?: Record; }; const binRelayMcp = pkg.bin?.['relay-mcp']; + const binRelay = pkg.bin?.relay; + if (binRelay !== './dist/cli/main.js') { + fail(`package.json#bin.relay must point to ./dist/cli/main.js (got ${String(binRelay)})`); + } if (binRelayMcp !== './dist/mcp/main.js') { fail( `package.json#bin.relay-mcp must point to ./dist/mcp/main.js (got ${String(binRelayMcp)})`, @@ -165,6 +173,23 @@ export function validateRepositoryAssets(options: ValidateRepositoryAssetsOption if (!existsSync(join(rootDir, 'dist', 'mcp', 'main.js'))) { fail('Built MCP executable missing at dist/mcp/main.js. Run pnpm build first.'); } + if (!existsSync(join(rootDir, 'dist', 'cli', 'main.js'))) { + fail('Built CLI executable missing at dist/cli/main.js. Run pnpm build first.'); + } + + const buildConfig = readFileSync(join(rootDir, 'tsup.config.ts'), 'utf-8'); + if ( + !buildConfig.includes("'cli/main'") || + !buildConfig.includes("'src/interfaces/cli/main.ts'") + ) { + fail('tsup.config.ts must build src/interfaces/cli/main.ts as cli/main.'); + } + + const readme = readFileSync(join(rootDir, 'README.md'), 'utf-8'); + const cliReference = readFileSync(join(rootDir, 'docs/cli-reference.md'), 'utf-8'); + if (!readme.includes('dist/cli/main.js') || !cliReference.includes('dist/cli/main.js')) { + fail('README.md and docs/cli-reference.md must document the built CLI invocation.'); + } const allFiles = walkFiles(rootDir); diff --git a/src/interfaces/cli/cli-command.ts b/src/interfaces/cli/cli-command.ts new file mode 100644 index 0000000..ab1b394 --- /dev/null +++ b/src/interfaces/cli/cli-command.ts @@ -0,0 +1,67 @@ +import type { EditTaskInput } from '../../application/tasks/task-application.js'; +import type { TaskPriority } from '../../domain/task/task-priority.js'; +import type { TaskStatus } from '../../domain/task/task-status.js'; + +export interface TaskCaptureCommand { + readonly kind: 'task.capture'; + readonly title: string; + readonly description?: string; + readonly priority?: TaskPriority; + readonly workspace?: string; + readonly sourceContext?: string; + readonly agent: string; + readonly sessionId: string; +} + +export interface TaskListCommand { + readonly kind: 'task.list'; + readonly statuses: readonly TaskStatus[]; + readonly workspace?: string; + readonly limit: number; +} + +export interface TaskGetCommand { + readonly kind: 'task.get'; + readonly id: string; +} + +export interface TaskFindSimilarCommand { + readonly kind: 'task.find-similar'; + readonly title: string; + readonly workspace?: string; + readonly limit: number; +} + +export interface TaskEditCommand { + readonly kind: 'task.edit'; + readonly id: string; + readonly changes: Omit; +} + +export interface TaskTriageCommand { + readonly kind: 'task.triage'; + readonly id: string; + readonly target: 'INBOX' | 'ACTIVE' | 'BACKLOG'; +} + +export interface TaskLifecycleCommand { + readonly kind: 'task.start' | 'task.complete' | 'task.archive'; + readonly id: string; + readonly action: 'start' | 'complete' | 'archive'; +} + +export interface SessionCapturesCommand { + readonly kind: 'session.captures'; + readonly sessionId: string; + readonly limit: number; +} + +export type CliCommand = + | TaskCaptureCommand + | TaskListCommand + | TaskGetCommand + | TaskFindSimilarCommand + | TaskEditCommand + | TaskTriageCommand + | TaskLifecycleCommand + | SessionCapturesCommand; diff --git a/src/interfaces/cli/commands/command-result.ts b/src/interfaces/cli/commands/command-result.ts new file mode 100644 index 0000000..fc7eac1 --- /dev/null +++ b/src/interfaces/cli/commands/command-result.ts @@ -0,0 +1,4 @@ +export interface CliCommandResult { + readonly data: Record; + readonly warnings?: readonly unknown[]; +} diff --git a/src/interfaces/cli/commands/session-captures.ts b/src/interfaces/cli/commands/session-captures.ts new file mode 100644 index 0000000..2c12627 --- /dev/null +++ b/src/interfaces/cli/commands/session-captures.ts @@ -0,0 +1,17 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; +import type { SessionCapturesCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +export function executeSessionCaptures( + command: SessionCapturesCommand, + application: TaskApplication, +): CliCommandResult { + const tasks = application.listSessionCaptures({ + sessionId: command.sessionId, + limit: command.limit, + }); + return { + data: { sessionId: command.sessionId, tasks: tasks.map(toTaskDto), count: tasks.length }, + }; +} diff --git a/src/interfaces/cli/commands/task-capture.ts b/src/interfaces/cli/commands/task-capture.ts new file mode 100644 index 0000000..45c1d4d --- /dev/null +++ b/src/interfaces/cli/commands/task-capture.ts @@ -0,0 +1,37 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; +import type { TaskCaptureCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +export function executeTaskCapture( + command: TaskCaptureCommand, + application: TaskApplication, +): CliCommandResult { + const matches = application.findSimilar({ + title: command.title, + ...(command.workspace === undefined ? {} : { workspace: command.workspace }), + limit: 5, + }); + const task = application.create({ + title: command.title, + ...(command.description === undefined ? {} : { description: command.description }), + ...(command.priority === undefined ? {} : { priority: command.priority }), + ...(command.workspace === undefined ? {} : { workspace: command.workspace }), + ...(command.sourceContext === undefined ? {} : { sourceContext: command.sourceContext }), + sessionId: command.sessionId, + creator: { type: 'AGENT', name: command.agent }, + }); + const warnings = matches.length + ? [ + { + code: 'POSSIBLE_DUPLICATE', + message: 'Similar tasks already exist.', + candidates: matches.map(({ id }) => ({ id })), + }, + ] + : []; + return { + data: { task: toTaskDto(task), change: { action: 'CREATED' } }, + warnings, + }; +} diff --git a/src/interfaces/cli/commands/task-edit.ts b/src/interfaces/cli/commands/task-edit.ts new file mode 100644 index 0000000..ff8f1d1 --- /dev/null +++ b/src/interfaces/cli/commands/task-edit.ts @@ -0,0 +1,15 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { editChange } from '../../contracts/change-metadata.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; +import type { TaskEditCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +export function executeTaskEdit( + command: TaskEditCommand, + application: TaskApplication, +): CliCommandResult { + const mutation = application.edit({ id: command.id, ...command.changes }); + return { + data: { task: toTaskDto(mutation.task), change: editChange(mutation.before, mutation.task) }, + }; +} diff --git a/src/interfaces/cli/commands/task-find-similar.ts b/src/interfaces/cli/commands/task-find-similar.ts new file mode 100644 index 0000000..4f8f377 --- /dev/null +++ b/src/interfaces/cli/commands/task-find-similar.ts @@ -0,0 +1,23 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { matchReason, toTaskDto } from '../../contracts/task-dto.js'; +import type { TaskFindSimilarCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +export function executeTaskFindSimilar( + command: TaskFindSimilarCommand, + application: TaskApplication, +): CliCommandResult { + const candidates = application.findSimilar({ + title: command.title, + limit: command.limit, + ...(command.workspace === undefined ? {} : { workspace: command.workspace }), + }); + return { + data: { + candidates: candidates.map((task) => ({ + task: toTaskDto(task), + matchReason: matchReason(task, command.title), + })), + }, + }; +} diff --git a/src/interfaces/cli/commands/task-get.ts b/src/interfaces/cli/commands/task-get.ts new file mode 100644 index 0000000..0863615 --- /dev/null +++ b/src/interfaces/cli/commands/task-get.ts @@ -0,0 +1,11 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; +import type { TaskGetCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +export function executeTaskGet( + command: TaskGetCommand, + application: TaskApplication, +): CliCommandResult { + return { data: { task: toTaskDto(application.get({ id: command.id })) } }; +} diff --git a/src/interfaces/cli/commands/task-lifecycle.ts b/src/interfaces/cli/commands/task-lifecycle.ts new file mode 100644 index 0000000..30b7c9a --- /dev/null +++ b/src/interfaces/cli/commands/task-lifecycle.ts @@ -0,0 +1,25 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { lifecycleChange } from '../../contracts/change-metadata.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; +import type { TaskLifecycleCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +const ACTIONS = { + start: ['start', 'STARTED'], + complete: ['complete', 'COMPLETED'], + archive: ['archive', 'ARCHIVED'], +} as const; + +export function executeTaskLifecycle( + command: TaskLifecycleCommand, + application: TaskApplication, +): CliCommandResult { + const [method, change] = ACTIONS[command.action]; + const mutation = application[method]({ id: command.id }); + return { + data: { + task: toTaskDto(mutation.task), + change: lifecycleChange(mutation.before, mutation.task, change), + }, + }; +} diff --git a/src/interfaces/cli/commands/task-list.ts b/src/interfaces/cli/commands/task-list.ts new file mode 100644 index 0000000..28c3955 --- /dev/null +++ b/src/interfaces/cli/commands/task-list.ts @@ -0,0 +1,16 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; +import type { TaskListCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +export function executeTaskList( + command: TaskListCommand, + application: TaskApplication, +): CliCommandResult { + const tasks = application.list({ + statuses: command.statuses, + limit: command.limit, + ...(command.workspace === undefined ? {} : { workspace: command.workspace }), + }); + return { data: { tasks: tasks.map(toTaskDto), count: tasks.length } }; +} diff --git a/src/interfaces/cli/commands/task-triage.ts b/src/interfaces/cli/commands/task-triage.ts new file mode 100644 index 0000000..783bf5c --- /dev/null +++ b/src/interfaces/cli/commands/task-triage.ts @@ -0,0 +1,20 @@ +import type { TaskApplication } from '../../../application/tasks/task-application.js'; +import { triageChange } from '../../contracts/change-metadata.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; +import type { TaskTriageCommand } from '../cli-command.js'; +import type { CliCommandResult } from './command-result.js'; + +export function executeTaskTriage( + command: TaskTriageCommand, + application: TaskApplication, +): CliCommandResult { + const mutation = + command.target === 'INBOX' + ? application.moveToInbox({ id: command.id }) + : command.target === 'ACTIVE' + ? application.activate({ id: command.id }) + : application.moveToBacklog({ id: command.id }); + return { + data: { task: toTaskDto(mutation.task), change: triageChange(mutation.before, mutation.task) }, + }; +} diff --git a/src/interfaces/cli/execute-cli-command.ts b/src/interfaces/cli/execute-cli-command.ts new file mode 100644 index 0000000..37fc155 --- /dev/null +++ b/src/interfaces/cli/execute-cli-command.ts @@ -0,0 +1,37 @@ +import type { TaskApplication } from '../../application/tasks/task-application.js'; +import type { CliCommand } from './cli-command.js'; +import { executeSessionCaptures } from './commands/session-captures.js'; +import type { CliCommandResult } from './commands/command-result.js'; +import { executeTaskCapture } from './commands/task-capture.js'; +import { executeTaskEdit } from './commands/task-edit.js'; +import { executeTaskFindSimilar } from './commands/task-find-similar.js'; +import { executeTaskGet } from './commands/task-get.js'; +import { executeTaskLifecycle } from './commands/task-lifecycle.js'; +import { executeTaskList } from './commands/task-list.js'; +import { executeTaskTriage } from './commands/task-triage.js'; + +export function executeCliCommand( + command: CliCommand, + application: TaskApplication, +): CliCommandResult { + switch (command.kind) { + case 'task.capture': + return executeTaskCapture(command, application); + case 'task.list': + return executeTaskList(command, application); + case 'task.get': + return executeTaskGet(command, application); + case 'task.find-similar': + return executeTaskFindSimilar(command, application); + case 'task.edit': + return executeTaskEdit(command, application); + case 'task.triage': + return executeTaskTriage(command, application); + case 'task.start': + case 'task.complete': + case 'task.archive': + return executeTaskLifecycle(command, application); + case 'session.captures': + return executeSessionCaptures(command, application); + } +} diff --git a/src/interfaces/cli/output/cli-errors.ts b/src/interfaces/cli/output/cli-errors.ts index dbee085..0f1ae77 100644 --- a/src/interfaces/cli/output/cli-errors.ts +++ b/src/interfaces/cli/output/cli-errors.ts @@ -9,6 +9,7 @@ import { TaskDomainError, TaskTransitionError, } from '../../../domain/task/task-errors.js'; +import { RelayError } from '../../../shared/errors.js'; export interface CliMappedError { readonly code: string; @@ -17,14 +18,17 @@ export interface CliMappedError { } export class CliUsageError extends Error {} -export function toCliError(error: unknown): CliMappedError { +export function toCliError( + error: unknown, + context: { readonly runtimeCreation?: boolean } = {}, +): CliMappedError { if (error instanceof TaskArchivedError) return { code: 'ARCHIVED_TASK', message: 'The task is archived.', exitCode: 4 }; if (error instanceof TaskTransitionError) return { code: 'CONFLICT', message: 'Task lifecycle transition is not allowed.', exitCode: 4 }; if (error instanceof TaskNotFoundError) return { code: 'NOT_FOUND', message: 'Task was not found.', exitCode: 3 }; - if (error instanceof TaskPersistenceError) + if (error instanceof TaskPersistenceError || error instanceof RelayError) return { code: 'STORAGE_ERROR', message: 'Task storage operation failed.', exitCode: 5 }; if ( error instanceof CliUsageError || @@ -37,5 +41,7 @@ export function toCliError(error: unknown): CliMappedError { message: error instanceof CliUsageError ? error.message : 'Request validation failed.', exitCode: 2, }; - return { code: 'INTERNAL_ERROR', message: 'An unexpected internal error occurred.', exitCode: 1 }; + return context.runtimeCreation + ? { code: 'STORAGE_ERROR', message: 'Task storage operation failed.', exitCode: 5 } + : { code: 'INTERNAL_ERROR', message: 'An unexpected internal error occurred.', exitCode: 1 }; } diff --git a/src/interfaces/cli/parse-cli.ts b/src/interfaces/cli/parse-cli.ts new file mode 100644 index 0000000..233b743 --- /dev/null +++ b/src/interfaces/cli/parse-cli.ts @@ -0,0 +1,346 @@ +import { isTaskPriority, type TaskPriority } from '../../domain/task/task-priority.js'; +import { isTaskStatus, TASK_STATUSES } from '../../domain/task/task-status.js'; +import { MAX_SESSION_ID_LENGTH, SESSION_ID_PATTERN } from '../../shared/session-id-rules.js'; +import { CliUsageError } from './output/cli-errors.js'; +import type { + CliCommand, + SessionCapturesCommand, + TaskCaptureCommand, + TaskEditCommand, + TaskFindSimilarCommand, + TaskGetCommand, + TaskLifecycleCommand, + TaskListCommand, + TaskTriageCommand, +} from './cli-command.js'; + +const MAX_ID_LENGTH = 100; +const MAX_TITLE_LENGTH = 300; +const MAX_DESCRIPTION_LENGTH = 10_000; +const MAX_WORKSPACE_LENGTH = 255; +const MAX_SOURCE_CONTEXT_LENGTH = 1_000; +const MAX_AGENT_LENGTH = 100; + +type OptionSpec = { readonly value: boolean; readonly repeatable?: boolean }; +type OptionSpecs = Readonly>; + +const OUTPUT_OPTION: OptionSpec = { value: true }; +const captureOptions: OptionSpecs = { + title: { value: true }, + agent: { value: true }, + session: { value: true }, + description: { value: true }, + priority: { value: true }, + workspace: { value: true }, + 'source-context': { value: true }, + output: OUTPUT_OPTION, +}; +const listOptions: OptionSpecs = { + status: { value: true, repeatable: true }, + workspace: { value: true }, + limit: { value: true }, + output: OUTPUT_OPTION, +}; +const getOptions: OptionSpecs = { output: OUTPUT_OPTION }; +const findSimilarOptions: OptionSpecs = { + title: { value: true }, + workspace: { value: true }, + limit: { value: true }, + output: OUTPUT_OPTION, +}; +const editOptions: OptionSpecs = { + title: { value: true }, + description: { value: true }, + priority: { value: true }, + workspace: { value: true }, + 'source-context': { value: true }, + 'clear-description': { value: false }, + 'clear-priority': { value: false }, + 'clear-workspace': { value: false }, + 'clear-source-context': { value: false }, + output: OUTPUT_OPTION, +}; +const triageOptions: OptionSpecs = { to: { value: true }, output: OUTPUT_OPTION }; +const lifecycleOptions: OptionSpecs = { output: OUTPUT_OPTION }; +const sessionOptions: OptionSpecs = { + session: { value: true }, + limit: { value: true }, + output: OUTPUT_OPTION, +}; + +export function parseCli(argv: readonly string[]): CliCommand { + const [group, action, ...tokens] = argv; + if (group !== 'task' && group !== 'session') { + throw new CliUsageError('Unknown or missing command.'); + } + if (action === undefined) { + throw new CliUsageError('Unknown or missing command.'); + } + + if (group === 'session') { + if (action !== 'captures') throw new CliUsageError(`Unknown command: session ${action}.`); + return parseSessionCaptures(tokens); + } + + const idActions = new Set(['get', 'edit', 'triage', 'start', 'complete', 'archive']); + const id = idActions.has(action) ? readId(tokens.shift(), 'task id') : undefined; + switch (action) { + case 'capture': + if (id !== undefined) throw new CliUsageError('task capture does not accept a task id.'); + return parseTaskCapture(tokens); + case 'list': + if (id !== undefined) throw new CliUsageError('task list does not accept a task id.'); + return parseTaskList(tokens); + case 'get': + return parseTaskGet(id, tokens); + case 'find-similar': + if (id !== undefined) throw new CliUsageError('task find-similar does not accept a task id.'); + return parseTaskFindSimilar(tokens); + case 'edit': + return parseTaskEdit(id, tokens); + case 'triage': + return parseTaskTriage(id, tokens); + case 'start': + case 'complete': + case 'archive': + return parseTaskLifecycle(action, id, tokens); + default: + throw new CliUsageError(`Unknown command: task ${action}.`); + } +} + +function parseTaskCapture(tokens: readonly string[]): TaskCaptureCommand { + const options = parseOptions(tokens, captureOptions); + requireJsonOutput(options); + return { + kind: 'task.capture', + title: requiredText(options, 'title', MAX_TITLE_LENGTH), + agent: requiredText(options, 'agent', MAX_AGENT_LENGTH), + sessionId: requiredSession(options, 'session'), + ...optionalTextProperty(options, 'description', MAX_DESCRIPTION_LENGTH), + ...optionalPriorityProperty(options), + ...optionalTextProperty(options, 'workspace', MAX_WORKSPACE_LENGTH), + ...optionalTextProperty(options, 'source-context', MAX_SOURCE_CONTEXT_LENGTH, 'sourceContext'), + }; +} + +function parseTaskList(tokens: readonly string[]): TaskListCommand { + const options = parseOptions(tokens, listOptions); + requireJsonOutput(options); + const rawStatuses = options.get('status'); + const statuses = + rawStatuses === undefined + ? [...TASK_STATUSES] + : rawStatuses.map((value) => enumValue(value, isTaskStatus, 'status')); + return { + kind: 'task.list', + statuses, + ...optionalTextProperty(options, 'workspace', MAX_WORKSPACE_LENGTH), + limit: integerOption(options, 'limit', 100, 100), + }; +} + +function parseTaskGet(id: string | undefined, tokens: readonly string[]): TaskGetCommand { + const taskId = requiredId(id); + const options = parseOptions(tokens, getOptions); + requireJsonOutput(options); + return { kind: 'task.get', id: taskId }; +} + +function parseTaskFindSimilar(tokens: readonly string[]): TaskFindSimilarCommand { + const options = parseOptions(tokens, findSimilarOptions); + requireJsonOutput(options); + return { + kind: 'task.find-similar', + title: requiredText(options, 'title', MAX_TITLE_LENGTH), + ...optionalTextProperty(options, 'workspace', MAX_WORKSPACE_LENGTH), + limit: integerOption(options, 'limit', 5, 5), + }; +} + +function parseTaskEdit(id: string | undefined, tokens: readonly string[]): TaskEditCommand { + const taskId = requiredId(id); + const options = parseOptions(tokens, editOptions); + requireJsonOutput(options); + assertClearConflicts(options); + const changes: TaskEditCommand['changes'] = { + ...optionalTextProperty(options, 'title', MAX_TITLE_LENGTH), + ...optionalTextProperty(options, 'description', MAX_DESCRIPTION_LENGTH), + ...optionalPriorityProperty(options), + ...optionalTextProperty(options, 'workspace', MAX_WORKSPACE_LENGTH), + ...optionalTextProperty(options, 'source-context', MAX_SOURCE_CONTEXT_LENGTH, 'sourceContext'), + ...(options.has('clear-description') ? { description: null } : {}), + ...(options.has('clear-priority') ? { priority: null } : {}), + ...(options.has('clear-workspace') ? { workspace: null } : {}), + ...(options.has('clear-source-context') ? { sourceContext: null } : {}), + }; + if (Object.keys(changes).length === 0) + throw new CliUsageError('At least one editable task field is required.'); + return { kind: 'task.edit', id: taskId, changes }; +} + +function assertClearConflicts(options: ReadonlyMap): void { + const pairs = [ + ['description', 'clear-description'], + ['priority', 'clear-priority'], + ['workspace', 'clear-workspace'], + ['source-context', 'clear-source-context'], + ] as const; + for (const [valueKey, clearKey] of pairs) { + if (options.has(valueKey) && options.has(clearKey)) { + throw new CliUsageError(`--${valueKey} cannot be supplied with --${clearKey}.`); + } + } +} + +function parseTaskTriage(id: string | undefined, tokens: readonly string[]): TaskTriageCommand { + const taskId = requiredId(id); + const options = parseOptions(tokens, triageOptions); + requireJsonOutput(options); + return { + kind: 'task.triage', + id: taskId, + target: enumValue(option(options, 'to'), isTriageTarget, 'to'), + }; +} + +function parseTaskLifecycle( + action: TaskLifecycleCommand['action'], + id: string | undefined, + tokens: readonly string[], +): TaskLifecycleCommand { + const taskId = requiredId(id); + const options = parseOptions(tokens, lifecycleOptions); + requireJsonOutput(options); + return { kind: `task.${action}`, action, id: taskId }; +} + +function parseSessionCaptures(tokens: readonly string[]): SessionCapturesCommand { + const options = parseOptions(tokens, sessionOptions); + requireJsonOutput(options); + return { + kind: 'session.captures', + sessionId: requiredSession(options, 'session'), + limit: integerOption(options, 'limit', 100, 100), + }; +} + +function parseOptions( + tokens: readonly string[], + specs: OptionSpecs, +): ReadonlyMap { + const options = new Map(); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === undefined || !token.startsWith('--')) { + throw new CliUsageError(`Unexpected argument: ${token ?? ''}`.trim()); + } + const key = token.slice(2); + const spec = specs[key]; + if (spec === undefined) throw new CliUsageError(`Unknown option --${key}.`); + const existing = options.get(key); + if (existing !== undefined && !spec.repeatable) + throw new CliUsageError(`Option --${key} may be supplied only once.`); + if (!spec.value) { + options.set(key, [...(existing ?? []), 'true']); + continue; + } + const value = tokens[index + 1]; + if (value === undefined || value.startsWith('--')) + throw new CliUsageError(`Missing value for --${key}.`); + index += 1; + options.set(key, [...(existing ?? []), value]); + } + return options; +} + +function requireJsonOutput(options: ReadonlyMap): void { + if (option(options, 'output') !== 'json') + throw new CliUsageError('All supported commands require --output json.'); +} + +function option(options: ReadonlyMap, key: string): string | undefined { + return options.get(key)?.[0]; +} + +function requiredText( + options: ReadonlyMap, + key: string, + maximum: number, +): string { + const value = option(options, key); + if (value === undefined) throw new CliUsageError(`Missing required option --${key}.`); + return boundedText(value, `--${key}`, maximum); +} + +function requiredSession(options: ReadonlyMap, key: string): string { + const value = requiredText(options, key, MAX_SESSION_ID_LENGTH); + if (!SESSION_ID_PATTERN.test(value)) + throw new CliUsageError(`--${key} is not a valid session id.`); + return value; +} + +function readId(value: string | undefined, label: string): string | undefined { + if (value === undefined || value.startsWith('--')) { + if (value?.startsWith('--')) throw new CliUsageError(`A ${label} is required.`); + return undefined; + } + return boundedText(value, label, MAX_ID_LENGTH); +} + +function requiredId(value: string | undefined): string { + if (value === undefined) throw new CliUsageError('A task id is required.'); + return value; +} + +function boundedText(value: string, label: string, maximum: number): string { + const normalized = value.trim(); + if (normalized.length === 0) throw new CliUsageError(`${label} must not be empty.`); + if (normalized.length > maximum) throw new CliUsageError(`${label} exceeds its maximum length.`); + return normalized; +} + +function optionalTextProperty( + options: ReadonlyMap, + key: string, + maximum: number, + property = key, +): Record { + const value = option(options, key); + return value === undefined ? {} : { [property]: boundedText(value, `--${key}`, maximum) }; +} + +function optionalPriorityProperty( + options: ReadonlyMap, +): Record { + const value = option(options, 'priority'); + return value === undefined ? {} : { priority: enumValue(value, isTaskPriority, 'priority') }; +} + +function integerOption( + options: ReadonlyMap, + key: string, + maximum: number, + fallback: number, +): number { + const raw = option(options, key); + if (raw === undefined) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > maximum) + throw new CliUsageError(`--${key} must be an integer from 1 through ${maximum}.`); + return value; +} + +function enumValue( + value: string | undefined, + predicate: (value: unknown) => value is T, + key: string, +): T { + if (value === undefined || !predicate(value)) + throw new CliUsageError(`--${key} has an invalid value.`); + return value; +} + +function isTriageTarget(value: unknown): value is 'INBOX' | 'ACTIVE' | 'BACKLOG' { + return value === 'INBOX' || value === 'ACTIVE' || value === 'BACKLOG'; +} diff --git a/src/interfaces/cli/run-cli.ts b/src/interfaces/cli/run-cli.ts index 6d215b7..a5a9439 100644 --- a/src/interfaces/cli/run-cli.ts +++ b/src/interfaces/cli/run-cli.ts @@ -1,14 +1,7 @@ -import type { - CreateTaskInput, - EditTaskInput, - TaskApplication, -} from '../../application/tasks/task-application.js'; -import type { TaskPriority } from '../../domain/task/task-priority.js'; -import { TASK_STATUSES, type TaskStatus } from '../../domain/task/task-status.js'; import type { TaskRuntime } from '../shared/create-task-runtime.js'; -import { editChange, lifecycleChange, triageChange } from '../mcp/mapping/change-metadata.js'; -import { matchReason, toTaskMcpDto } from '../mcp/mapping/task-mcp-dto.js'; -import { CliUsageError, toCliError } from './output/cli-errors.js'; +import { executeCliCommand } from './execute-cli-command.js'; +import { parseCli } from './parse-cli.js'; +import { toCliError } from './output/cli-errors.js'; import { cliFailure, cliSuccess } from './output/cli-result.js'; type Writer = { write(text: string): unknown }; @@ -17,225 +10,67 @@ export interface CliDependencies { readonly stdout: Writer; readonly stderr: Writer; } -type Command = { - readonly group: 'task' | 'session'; - readonly action: string; - readonly id?: string; - readonly options: ReadonlyMap; -}; export async function runCli( argv: readonly string[], dependencies: CliDependencies, ): Promise { + let command; try { - const command = parse(argv); - const runtime = dependencies.createRuntime(); - try { - const result = execute(command, runtime.taskApplication); - const { warnings = [], ...data } = result; - write(dependencies.stdout, cliSuccess(data, warnings as readonly unknown[])); - return 0; - } catch (error) { - return writeError(error, dependencies); - } finally { - runtime.close(); - } + command = parseCli(argv); } catch (error) { return writeError(error, dependencies); } + + let runtime: TaskRuntime; + try { + runtime = dependencies.createRuntime(); + } catch (error) { + return writeError(error, dependencies, { runtimeCreation: true }); + } + + let result: ReturnType | undefined; + let executionFailed = false; + let executionError: unknown; + try { + result = executeCliCommand(command, runtime.taskApplication); + } catch (error) { + executionFailed = true; + executionError = error; + } + + let cleanupFailed = false; + let cleanupError: unknown; + try { + runtime.close(); + } catch (error) { + cleanupFailed = true; + cleanupError = error; + } + + if (executionFailed) { + return writeError(executionError, dependencies); + } + if (cleanupFailed) { + return writeError(cleanupError, dependencies); + } + + const { data, warnings = [] } = result!; + write(dependencies.stdout, cliSuccess(data, warnings)); + return 0; } -function writeError(error: unknown, { stdout, stderr }: CliDependencies): number { - const mapped = toCliError(error); +function writeError( + error: unknown, + { stdout, stderr }: CliDependencies, + context?: { readonly runtimeCreation?: boolean }, +): number { + const mapped = toCliError(error, context); write(stdout, cliFailure(mapped.code, mapped.message)); stderr.write(`${mapped.message}\n`); return mapped.exitCode; } + function write(writer: Writer, value: unknown): void { writer.write(`${JSON.stringify(value)}\n`); } - -function parse(argv: readonly string[]): Command { - const [group, action, ...rest] = argv; - if ((group !== 'task' && group !== 'session') || !action) - throw new CliUsageError('Unknown or missing command.'); - const needsId = - group === 'task' && ['get', 'edit', 'triage', 'start', 'complete', 'archive'].includes(action); - const id = needsId ? rest.shift() : undefined; - if (needsId && (!id || id.startsWith('--'))) throw new CliUsageError('A task id is required.'); - const options = new Map(); - for (let index = 0; index < rest.length; index += 1) { - const token = rest[index]; - if (!token?.startsWith('--')) throw new CliUsageError(`Unexpected argument: ${token}`); - const key = token.slice(2); - const flags = new Set([ - 'clear-description', - 'clear-priority', - 'clear-workspace', - 'clear-source-context', - ]); - const value = flags.has(key) ? 'true' : rest[++index]; - if (value === undefined || value.startsWith('--')) - throw new CliUsageError(`Missing value for --${key}.`); - const existing = options.get(key) ?? []; - if (key !== 'status' && existing.length) - throw new CliUsageError(`Option --${key} may be supplied only once.`); - options.set(key, [...existing, value]); - } - if (option(options, 'output', false) !== 'json') - throw new CliUsageError('All supported commands require --output json.'); - return { group, action, ...(id === undefined ? {} : { id }), options }; -} -function option( - options: ReadonlyMap, - key: string, - required = false, -): string | undefined { - const value = options.get(key)?.[0]; - if (required && value === undefined) throw new CliUsageError(`Missing required option --${key}.`); - return value; -} -function numberOption( - options: ReadonlyMap, - key: string, - maximum: number, - fallback: number, -): number { - const raw = option(options, key); - if (raw === undefined) return fallback; - const value = Number(raw); - if (!Number.isInteger(value) || value < 1 || value > maximum) - throw new CliUsageError(`--${key} must be an integer from 1 through ${maximum}.`); - return value; -} -function optionalFields( - command: Command, -): Omit { - const o = command.options; - const description = option(o, 'description'); - const priority = option(o, 'priority'); - const workspace = option(o, 'workspace'); - const sourceContext = option(o, 'source-context'); - return { - ...(description === undefined ? {} : { description }), - ...(priority === undefined ? {} : { priority: priority as TaskPriority }), - ...(workspace === undefined ? {} : { workspace }), - ...(sourceContext === undefined ? {} : { sourceContext }), - }; -} -function workspaceOption(options: ReadonlyMap) { - const workspace = option(options, 'workspace'); - return workspace === undefined ? {} : { workspace }; -} - -function execute(command: Command, application: TaskApplication): Record { - const o = command.options; - if (command.group === 'session' && command.action === 'captures') { - const sessionId = option(o, 'session', true)!; - const tasks = application.listSessionCaptures({ - sessionId, - limit: numberOption(o, 'limit', 100, 100), - }); - return { sessionId, tasks: tasks.map(toTaskMcpDto), count: tasks.length }; - } - if (command.group !== 'task') throw new CliUsageError('Unknown command.'); - if (command.action === 'get') return { task: toTaskMcpDto(application.get({ id: command.id! })) }; - if (command.action === 'list') { - const statuses = (o.get('status') ?? [...TASK_STATUSES]) as readonly TaskStatus[]; - const tasks = application.list({ - statuses, - limit: numberOption(o, 'limit', 100, 100), - ...workspaceOption(o), - }); - return { tasks: tasks.map(toTaskMcpDto), count: tasks.length }; - } - if (command.action === 'find-similar') { - const title = option(o, 'title', true)!; - const candidates = application.findSimilar({ - title, - limit: numberOption(o, 'limit', 5, 5), - ...workspaceOption(o), - }); - return { - candidates: candidates.map((task) => ({ - task: toTaskMcpDto(task), - matchReason: matchReason(task, title), - })), - }; - } - if (command.action === 'capture') { - const title = option(o, 'title', true)!; - const matches = application.findSimilar({ title, limit: 5, ...workspaceOption(o) }); - const task = application.create({ - title, - ...optionalFields(command), - sessionId: option(o, 'session', true)!, - creator: { type: 'AGENT', name: option(o, 'agent', true)! }, - }); - return { - task: toTaskMcpDto(task), - change: { action: 'CREATED' }, - warnings: matches.length - ? [ - { - code: 'POSSIBLE_DUPLICATE', - message: 'Similar tasks already exist.', - candidates: matches.map(({ id }) => ({ id })), - }, - ] - : [], - }; - } - if (command.action === 'edit') { - const fields = optionalFields(command); - const clears = { - ...(o.has('clear-description') ? { description: null } : {}), - ...(o.has('clear-priority') ? { priority: null } : {}), - ...(o.has('clear-workspace') ? { workspace: null } : {}), - ...(o.has('clear-source-context') ? { sourceContext: null } : {}), - }; - const title = option(o, 'title'); - if (!Object.keys(fields).length && !Object.keys(clears).length && title === undefined) - throw new CliUsageError('At least one editable task field is required.'); - const mutation = application.edit({ - id: command.id!, - ...(title === undefined ? {} : { title }), - ...fields, - ...clears, - } as EditTaskInput); - return { - task: toTaskMcpDto(mutation.task), - change: editChange(mutation.before, mutation.task), - }; - } - if (command.action === 'triage') { - const target = option(o, 'to', true)!; - const mutation = - target === 'INBOX' - ? application.moveToInbox({ id: command.id! }) - : target === 'ACTIVE' - ? application.activate({ id: command.id! }) - : target === 'BACKLOG' - ? application.moveToBacklog({ id: command.id! }) - : (() => { - throw new CliUsageError('--to must be INBOX, ACTIVE, or BACKLOG.'); - })(); - return { - task: toTaskMcpDto(mutation.task), - change: triageChange(mutation.before, mutation.task), - }; - } - const methods = { - start: ['start', 'STARTED'], - complete: ['complete', 'COMPLETED'], - archive: ['archive', 'ARCHIVED'], - } as const; - const method = methods[command.action as keyof typeof methods]; - if (!method) throw new CliUsageError('Unknown command.'); - const mutation = application[method[0]]({ id: command.id! }); - return { - task: toTaskMcpDto(mutation.task), - change: lifecycleChange(mutation.before, mutation.task, method[1]), - }; -} diff --git a/src/interfaces/mcp/mapping/change-metadata.ts b/src/interfaces/contracts/change-metadata.ts similarity index 93% rename from src/interfaces/mcp/mapping/change-metadata.ts rename to src/interfaces/contracts/change-metadata.ts index 4c3bf69..c310556 100644 --- a/src/interfaces/mcp/mapping/change-metadata.ts +++ b/src/interfaces/contracts/change-metadata.ts @@ -1,4 +1,4 @@ -import type { Task } from '../../../domain/task/task.js'; +import type { Task } from '../../domain/task/task.js'; const EDITABLE_FIELDS = ['title', 'description', 'priority', 'workspace', 'sourceContext'] as const; diff --git a/src/interfaces/contracts/task-dto.ts b/src/interfaces/contracts/task-dto.ts new file mode 100644 index 0000000..fe2d727 --- /dev/null +++ b/src/interfaces/contracts/task-dto.ts @@ -0,0 +1,27 @@ +import type { Task } from '../../domain/task/task.js'; + +export interface TaskDto { + readonly id: string; + readonly title: string; + readonly description: string | null; + readonly status: Task['status']; + readonly priority: Task['priority']; + readonly workspace: string | null; + readonly sourceContext: string | null; + readonly createdByType: Task['createdByType']; + readonly createdByName: string | null; + readonly sessionId: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly archivedAt: string | null; +} + +export function toTaskDto(task: Task): TaskDto { + return { ...task }; +} + +export function matchReason(task: Task, title: string): 'EXACT_TITLE' | 'NORMALIZED_TITLE' { + return task.title === title.trim() ? 'EXACT_TITLE' : 'NORMALIZED_TITLE'; +} diff --git a/src/interfaces/http/task-dto.ts b/src/interfaces/http/task-dto.ts index 88bdbce..f1b2e6e 100644 --- a/src/interfaces/http/task-dto.ts +++ b/src/interfaces/http/task-dto.ts @@ -1,23 +1 @@ -import type { Task } from '../../domain/task/task.js'; - -export interface TaskDto { - readonly id: string; - readonly title: string; - readonly description: string | null; - readonly status: Task['status']; - readonly priority: Task['priority']; - readonly workspace: string | null; - readonly sourceContext: string | null; - readonly createdByType: Task['createdByType']; - readonly createdByName: string | null; - readonly sessionId: string | null; - readonly createdAt: string; - readonly updatedAt: string; - readonly startedAt: string | null; - readonly completedAt: string | null; - readonly archivedAt: string | null; -} - -export function toTaskDto(task: Task): TaskDto { - return { ...task }; -} +export { toTaskDto, type TaskDto } from '../contracts/task-dto.js'; diff --git a/src/interfaces/mcp/mapping/task-mcp-dto.ts b/src/interfaces/mcp/mapping/task-mcp-dto.ts deleted file mode 100644 index 2883f8c..0000000 --- a/src/interfaces/mcp/mapping/task-mcp-dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Task } from '../../../domain/task/task.js'; -import type { TaskDto } from '../../http/task-dto.js'; - -export function toTaskMcpDto(task: Task): TaskDto { - return { ...task }; -} -export function matchReason(task: Task, title: string): 'EXACT_TITLE' | 'NORMALIZED_TITLE' { - return task.title === title.trim() ? 'EXACT_TITLE' : 'NORMALIZED_TITLE'; -} diff --git a/src/interfaces/mcp/tools/register-read-tools.ts b/src/interfaces/mcp/tools/register-read-tools.ts index 5a44d5c..f323a88 100644 --- a/src/interfaces/mcp/tools/register-read-tools.ts +++ b/src/interfaces/mcp/tools/register-read-tools.ts @@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TaskApplication } from '../../../application/tasks/task-application.js'; import { toMcpError } from '../mapping/mcp-errors.js'; import { mcpSuccess } from '../mapping/mcp-result.js'; -import { matchReason, toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { matchReason, toTaskDto } from '../../contracts/task-dto.js'; import { findSimilarInputSchema, sessionCapturesOutputSchema, @@ -31,7 +31,7 @@ export function registerReadTools(server: McpServer, taskApplication: TaskApplic limit: parsed.limit, ...(parsed.workspace === undefined ? {} : { workspace: parsed.workspace }), }); - return mcpSuccess({ tasks: tasks.map(toTaskMcpDto), count: tasks.length }); + return mcpSuccess({ tasks: tasks.map(toTaskDto), count: tasks.length }); } catch (error) { return toMcpError(error); } @@ -47,7 +47,7 @@ export function registerReadTools(server: McpServer, taskApplication: TaskApplic async (input) => { try { const parsed = input; - return mcpSuccess({ task: toTaskMcpDto(taskApplication.get({ id: parsed.taskId })) }); + return mcpSuccess({ task: toTaskDto(taskApplication.get({ id: parsed.taskId })) }); } catch (error) { return toMcpError(error); } @@ -70,7 +70,7 @@ export function registerReadTools(server: McpServer, taskApplication: TaskApplic ...(parsed.workspace === undefined ? {} : { workspace: parsed.workspace }), }) .map((task) => ({ - task: toTaskMcpDto(task), + task: toTaskDto(task), matchReason: matchReason(task, parsed.title), })); return mcpSuccess({ candidates }); @@ -92,7 +92,7 @@ export function registerReadTools(server: McpServer, taskApplication: TaskApplic const tasks = taskApplication.listSessionCaptures(parsed); return mcpSuccess({ sessionId: parsed.sessionId, - tasks: tasks.map(toTaskMcpDto), + tasks: tasks.map(toTaskDto), count: tasks.length, }); } catch (error) { diff --git a/src/interfaces/mcp/tools/task-archive.ts b/src/interfaces/mcp/tools/task-archive.ts index 04afb5c..36d7f5a 100644 --- a/src/interfaces/mcp/tools/task-archive.ts +++ b/src/interfaces/mcp/tools/task-archive.ts @@ -1,9 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TaskApplication } from '../../../application/tasks/task-application.js'; -import { lifecycleChange } from '../mapping/change-metadata.js'; +import { lifecycleChange } from '../../contracts/change-metadata.js'; import { toMcpError } from '../mapping/mcp-errors.js'; import { mcpSuccess } from '../mapping/mcp-result.js'; -import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; import { taskArchiveInputSchema, taskArchiveOutputSchema, @@ -21,7 +21,7 @@ export function registerTaskArchiveTool(server: McpServer, application: TaskAppl try { const mutation = application.archive({ id: input.taskId }); return mcpSuccess({ - task: toTaskMcpDto(mutation.task), + task: toTaskDto(mutation.task), change: lifecycleChange(mutation.before, mutation.task, 'ARCHIVED'), }); } catch (error) { diff --git a/src/interfaces/mcp/tools/task-capture.ts b/src/interfaces/mcp/tools/task-capture.ts index ffe09f1..ac80134 100644 --- a/src/interfaces/mcp/tools/task-capture.ts +++ b/src/interfaces/mcp/tools/task-capture.ts @@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TaskApplication } from '../../../application/tasks/task-application.js'; import { toMcpError } from '../mapping/mcp-errors.js'; import { mcpSuccess } from '../mapping/mcp-result.js'; -import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; import { agentCaptureInputSchema, taskCaptureOutputSchema } from '../schemas/read-tool-schemas.js'; export function registerTaskCaptureTool(server: McpServer, taskApplication: TaskApplication): void { @@ -40,7 +40,7 @@ export function registerTaskCaptureTool(server: McpServer, taskApplication: Task candidates: matches.map((candidate) => ({ id: candidate.id })), }, ]; - return mcpSuccess({ task: toTaskMcpDto(task), change: { action: 'CREATED' } }, warnings); + return mcpSuccess({ task: toTaskDto(task), change: { action: 'CREATED' } }, warnings); } catch (error) { return toMcpError(error); } diff --git a/src/interfaces/mcp/tools/task-complete.ts b/src/interfaces/mcp/tools/task-complete.ts index 807c042..5bba2ee 100644 --- a/src/interfaces/mcp/tools/task-complete.ts +++ b/src/interfaces/mcp/tools/task-complete.ts @@ -1,9 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TaskApplication } from '../../../application/tasks/task-application.js'; -import { lifecycleChange } from '../mapping/change-metadata.js'; +import { lifecycleChange } from '../../contracts/change-metadata.js'; import { toMcpError } from '../mapping/mcp-errors.js'; import { mcpSuccess } from '../mapping/mcp-result.js'; -import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; import { taskCompleteInputSchema, taskCompleteOutputSchema, @@ -21,7 +21,7 @@ export function registerTaskCompleteTool(server: McpServer, application: TaskApp try { const mutation = await application.complete({ id: input.taskId }); return mcpSuccess({ - task: toTaskMcpDto(mutation.task), + task: toTaskDto(mutation.task), change: lifecycleChange(mutation.before, mutation.task, 'COMPLETED'), }); } catch (error) { diff --git a/src/interfaces/mcp/tools/task-edit.ts b/src/interfaces/mcp/tools/task-edit.ts index 76c613d..054984a 100644 --- a/src/interfaces/mcp/tools/task-edit.ts +++ b/src/interfaces/mcp/tools/task-edit.ts @@ -1,9 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TaskApplication } from '../../../application/tasks/task-application.js'; -import { editChange } from '../mapping/change-metadata.js'; +import { editChange } from '../../contracts/change-metadata.js'; import { toMcpError } from '../mapping/mcp-errors.js'; import { mcpSuccess } from '../mapping/mcp-result.js'; -import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; import { taskEditInputSchema, taskEditOutputSchema } from '../schemas/mutation-tool-schemas.js'; const fieldUpdate = ( @@ -32,7 +32,7 @@ export function registerTaskEditTool(server: McpServer, application: TaskApplica ...fieldUpdate('sourceContext', input.sourceContext, input.clearSourceContext), }); return mcpSuccess({ - task: toTaskMcpDto(mutation.task), + task: toTaskDto(mutation.task), change: editChange(mutation.before, mutation.task), }); } catch (error) { diff --git a/src/interfaces/mcp/tools/task-start.ts b/src/interfaces/mcp/tools/task-start.ts index 571499b..ddb8ebd 100644 --- a/src/interfaces/mcp/tools/task-start.ts +++ b/src/interfaces/mcp/tools/task-start.ts @@ -1,9 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TaskApplication } from '../../../application/tasks/task-application.js'; -import { lifecycleChange } from '../mapping/change-metadata.js'; +import { lifecycleChange } from '../../contracts/change-metadata.js'; import { toMcpError } from '../mapping/mcp-errors.js'; import { mcpSuccess } from '../mapping/mcp-result.js'; -import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; import { taskStartInputSchema, taskStartOutputSchema } from '../schemas/mutation-tool-schemas.js'; export function registerTaskStartTool(server: McpServer, application: TaskApplication): void { @@ -18,7 +18,7 @@ export function registerTaskStartTool(server: McpServer, application: TaskApplic try { const mutation = await application.start({ id: input.taskId }); return mcpSuccess({ - task: toTaskMcpDto(mutation.task), + task: toTaskDto(mutation.task), change: lifecycleChange(mutation.before, mutation.task, 'STARTED'), }); } catch (error) { diff --git a/src/interfaces/mcp/tools/task-triage.ts b/src/interfaces/mcp/tools/task-triage.ts index 9abf4c1..6bd63c4 100644 --- a/src/interfaces/mcp/tools/task-triage.ts +++ b/src/interfaces/mcp/tools/task-triage.ts @@ -1,9 +1,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { TaskApplication } from '../../../application/tasks/task-application.js'; -import { triageChange } from '../mapping/change-metadata.js'; +import { triageChange } from '../../contracts/change-metadata.js'; import { toMcpError } from '../mapping/mcp-errors.js'; import { mcpSuccess } from '../mapping/mcp-result.js'; -import { toTaskMcpDto } from '../mapping/task-mcp-dto.js'; +import { toTaskDto } from '../../contracts/task-dto.js'; import { taskTriageInputSchema, taskTriageOutputSchema } from '../schemas/mutation-tool-schemas.js'; export function registerTaskTriageTool(server: McpServer, application: TaskApplication): void { @@ -23,7 +23,7 @@ export function registerTaskTriageTool(server: McpServer, application: TaskAppli ? application.activate({ id: input.taskId }) : application.moveToBacklog({ id: input.taskId }); return mcpSuccess({ - task: toTaskMcpDto(mutation.task), + task: toTaskDto(mutation.task), change: triageChange(mutation.before, mutation.task), }); } catch (error) { diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts new file mode 100644 index 0000000..826959e --- /dev/null +++ b/tests/integration/cli.test.ts @@ -0,0 +1,183 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const cliPath = join(repositoryRoot, 'dist', 'cli', 'main.js'); + +interface CliRun { + readonly status: number | null; + readonly stdout: string; + readonly stderr: string; +} + +interface CliEnvelope { + readonly ok: boolean; + readonly data: { + readonly task: { readonly id: string; readonly title?: string; readonly status?: string }; + readonly count: number; + readonly change: { readonly action: string; readonly from?: string; readonly to?: string }; + }; + readonly error: { readonly code: string; readonly message: string }; +} + +function runCli(workspace: string, databasePath: string, args: readonly string[]): CliRun { + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd: workspace, + env: { ...process.env, RELAY_DB_PATH: databasePath }, + encoding: 'utf8', + }); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +function parseSingleEnvelope(run: CliRun): CliEnvelope { + expect(run.stdout.endsWith('\n')).toBe(true); + expect(run.stdout.trim().split('\n')).toHaveLength(1); + return JSON.parse(run.stdout) as CliEnvelope; +} + +describe('built CLI', () => { + beforeAll(() => { + if (process.platform === 'win32') { + execFileSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'pnpm build:node'], { + cwd: repositoryRoot, + stdio: 'pipe', + }); + } else { + execFileSync('pnpm', ['build:node'], { cwd: repositoryRoot, stdio: 'pipe' }); + } + }); + + it('persists task operations across short-lived processes from an arbitrary CWD', () => { + const workspace = mkdtempSync(join(tmpdir(), 'relay-cli-cwd-')); + const databasePath = join(workspace, 'relay.db'); + try { + const capture = runCli(workspace, databasePath, [ + 'task', + 'capture', + '--title', + 'Built CLI task', + '--agent', + 'Codex', + '--session', + 'session-built', + '--workspace', + 'relay', + '--output', + 'json', + ]); + expect(capture.status).toBe(0); + expect(capture.stderr).toBe(''); + const captureEnvelope = parseSingleEnvelope(capture); + const taskId = captureEnvelope.data.task.id as string; + + const get = runCli(workspace, databasePath, ['task', 'get', taskId, '--output', 'json']); + expect(get.status).toBe(0); + expect(get.stderr).toBe(''); + expect(parseSingleEnvelope(get).data.task).toMatchObject({ + id: taskId, + title: 'Built CLI task', + }); + + const list = runCli(workspace, databasePath, [ + 'task', + 'list', + '--status', + 'INBOX', + '--workspace', + 'relay', + '--output', + 'json', + ]); + expect(list.status).toBe(0); + expect(parseSingleEnvelope(list).data.count).toBe(1); + + const session = runCli(workspace, databasePath, [ + 'session', + 'captures', + '--session', + 'session-built', + '--output', + 'json', + ]); + expect(session.status).toBe(0); + expect(parseSingleEnvelope(session).data.count).toBe(1); + + const triage = runCli(workspace, databasePath, [ + 'task', + 'triage', + taskId, + '--to', + 'ACTIVE', + '--output', + 'json', + ]); + expect(triage.status).toBe(0); + expect(parseSingleEnvelope(triage).data.change).toEqual({ + action: 'TRIAGED', + from: 'INBOX', + to: 'ACTIVE', + }); + + const start = runCli(workspace, databasePath, ['task', 'start', taskId, '--output', 'json']); + expect(start.status).toBe(0); + expect(parseSingleEnvelope(start).data.task.status).toBe('IN_PROGRESS'); + + const complete = runCli(workspace, databasePath, [ + 'task', + 'complete', + taskId, + '--output', + 'json', + ]); + expect(complete.status).toBe(0); + expect(parseSingleEnvelope(complete).data.task.status).toBe('DONE'); + + const archive = runCli(workspace, databasePath, [ + 'task', + 'archive', + taskId, + '--output', + 'json', + ]); + expect(archive.status).toBe(0); + expect(parseSingleEnvelope(archive).data.task.status).toBe('ARCHIVED'); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it('validates before runtime creation and maps an isolated storage failure', () => { + const workspace = mkdtempSync(join(tmpdir(), 'relay-cli-validation-')); + const databasePath = join(workspace, 'nested', 'relay.db'); + try { + const invalid = runCli(workspace, databasePath, ['task', 'get']); + expect(invalid.status).toBe(2); + expect(parseSingleEnvelope(invalid)).toMatchObject({ + ok: false, + error: { code: 'VALIDATION_ERROR' }, + }); + expect(invalid.stderr).toContain('task id'); + expect(existsSync(databasePath)).toBe(false); + + const storageDirectory = join(workspace, 'database-directory'); + mkdirSync(storageDirectory); + const storage = runCli(workspace, storageDirectory, ['task', 'list', '--output', 'json']); + expect(storage.status).toBe(5); + expect(parseSingleEnvelope(storage)).toMatchObject({ + ok: false, + error: { code: 'STORAGE_ERROR' }, + }); + expect(storage.stderr).toContain('Task storage operation failed.'); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/integration/mcp-cli-parity.test.ts b/tests/integration/mcp-cli-parity.test.ts new file mode 100644 index 0000000..d0be043 --- /dev/null +++ b/tests/integration/mcp-cli-parity.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createTaskApplication, + type TaskApplication, +} from '../../src/application/tasks/task-application.js'; +import type { Task } from '../../src/domain/task/task.js'; +import { createMcpServer } from '../../src/interfaces/mcp/create-mcp-server.js'; +import { runCli } from '../../src/interfaces/cli/run-cli.js'; +import type { TaskRuntime } from '../../src/interfaces/shared/create-task-runtime.js'; +import { + FixedClock, + FixedIdGenerator, + InMemoryTaskRepository, +} from '../unit/application/tasks/task-test-fixtures.js'; +import { connectMcp } from '../unit/interfaces/mcp/mcp-test-utils.js'; + +const FIXED_NOW = new Date('2026-07-27T10:00:00.000Z'); + +function createApplication(): TaskApplication { + return createTaskApplication({ + repository: new InMemoryTaskRepository(), + clock: new FixedClock(FIXED_NOW), + idGenerator: new FixedIdGenerator('task-1'), + }); +} + +function seedApplication(): { application: TaskApplication; task: Task } { + const application = createApplication(); + const task = application.create({ + title: 'Prepare release', + description: 'Details', + priority: 'LOW', + workspace: 'relay', + sourceContext: 'issue-22', + sessionId: 'session-a', + creator: { type: 'AGENT', name: 'Codex' }, + }); + return { application, task }; +} + +async function callMcp( + application: TaskApplication, + name: string, + arguments_: Record, +): Promise<{ data?: unknown; error?: unknown }> { + const { client, close } = await connectMcp(createMcpServer(application)); + try { + const result = (await client.callTool({ name, arguments: arguments_ })) as { + structuredContent?: { data?: unknown; error?: unknown }; + }; + return result.structuredContent ?? {}; + } finally { + await close(); + } +} + +async function callCli(application: TaskApplication, argv: readonly string[]) { + const stdout = { write: vi.fn() }; + const stderr = { write: vi.fn() }; + const runtime: TaskRuntime = { taskApplication: application, close: vi.fn() }; + const exitCode = await runCli(argv, { createRuntime: () => runtime, stdout, stderr }); + const envelope = JSON.parse(stdout.write.mock.calls[0]?.[0] as string) as { + data?: unknown; + error?: unknown; + }; + return { exitCode, data: envelope.data, error: envelope.error, stderr }; +} + +describe('MCP and CLI semantic parity', () => { + it('matches capture payloads and duplicate warnings', async () => { + const cli = await callCli(createApplication(), [ + 'task', + 'capture', + '--title', + 'Prepare release', + '--agent', + 'Codex', + '--session', + 'session-a', + '--workspace', + 'relay', + '--output', + 'json', + ]); + const mcp = await callMcp(createApplication(), 'task_capture', { + title: 'Prepare release', + createdByName: 'Codex', + sessionId: 'session-a', + workspace: 'relay', + }); + expect(cli.exitCode).toBe(0); + expect(cli.data).toEqual(mcp.data); + expect(cli.data).toMatchObject({ change: { action: 'CREATED' }, task: { id: 'task-1' } }); + expect(cli.data).toHaveProperty('task'); + }); + + it.each([ + [ + 'list', + ['task', 'list', '--status', 'INBOX', '--workspace', 'relay', '--output', 'json'], + 'task_list', + { statuses: ['INBOX'], workspace: 'relay' }, + ], + ['get', ['task', 'get', 'task-1', '--output', 'json'], 'task_get', { taskId: 'task-1' }], + [ + 'find similar', + [ + 'task', + 'find-similar', + '--title', + 'Prepare release', + '--workspace', + 'relay', + '--output', + 'json', + ], + 'task_find_similar', + { title: 'Prepare release', workspace: 'relay' }, + ], + [ + 'session captures', + ['session', 'captures', '--session', 'session-a', '--output', 'json'], + 'session_captures_list', + { sessionId: 'session-a' }, + ], + ] as const)('matches %s read payloads', async (_name, cliArgs, mcpName, mcpArgs) => { + const cliApp = seedApplication().application; + const mcpApp = seedApplication().application; + const cli = await callCli(cliApp, cliArgs); + const mcp = await callMcp(mcpApp, mcpName, mcpArgs); + expect(cli.exitCode).toBe(0); + expect(cli.data).toEqual(mcp.data); + }); + + it.each([ + [ + 'edit', + ['task', 'edit', 'task-1', '--title', 'Updated', '--output', 'json'], + 'task_edit', + { taskId: 'task-1', title: 'Updated' }, + ], + [ + 'clear', + ['task', 'edit', 'task-1', '--clear-description', '--output', 'json'], + 'task_edit', + { taskId: 'task-1', clearDescription: true }, + ], + [ + 'no-op', + ['task', 'edit', 'task-1', '--title', 'Prepare release', '--output', 'json'], + 'task_edit', + { taskId: 'task-1', title: 'Prepare release' }, + ], + ] as const)('matches %s edit payloads', async (_name, cliArgs, mcpName, mcpArgs) => { + const cli = await callCli(seedApplication().application, cliArgs); + const mcp = await callMcp(seedApplication().application, mcpName, mcpArgs); + expect(cli.exitCode).toBe(0); + expect(cli.data).toEqual(mcp.data); + }); + + it.each(['INBOX', 'ACTIVE', 'BACKLOG'] as const)( + 'matches triage payload for %s', + async (target) => { + const cli = await callCli(seedApplication().application, [ + 'task', + 'triage', + 'task-1', + '--to', + target, + '--output', + 'json', + ]); + const mcp = await callMcp(seedApplication().application, 'task_triage', { + taskId: 'task-1', + target, + }); + expect(cli.exitCode).toBe(0); + expect(cli.data).toEqual(mcp.data); + }, + ); + + it.each([ + ['start', 'task_start', 'start'], + ['complete', 'task_complete', 'complete'], + ['archive', 'task_archive', 'archive'], + ] as const)('matches %s lifecycle payloads', async (_name, mcpName, action) => { + const cliFixture = seedApplication(); + const mcpFixture = seedApplication(); + cliFixture.application.activate({ id: 'task-1' }); + mcpFixture.application.activate({ id: 'task-1' }); + if (action !== 'start') { + cliFixture.application.start({ id: 'task-1' }); + mcpFixture.application.start({ id: 'task-1' }); + if (action === 'archive') { + cliFixture.application.complete({ id: 'task-1' }); + mcpFixture.application.complete({ id: 'task-1' }); + } + } + const cli = await callCli(cliFixture.application, [ + 'task', + action, + 'task-1', + '--output', + 'json', + ]); + const mcp = await callMcp(mcpFixture.application, mcpName, { taskId: 'task-1' }); + expect(cli.exitCode).toBe(0); + expect(cli.data).toEqual(mcp.data); + }); + + it('matches not-found and conflict error semantics', async () => { + const cliNotFound = await callCli(createApplication(), [ + 'task', + 'get', + 'missing', + '--output', + 'json', + ]); + const mcpNotFound = await callMcp(createApplication(), 'task_get', { taskId: 'missing' }); + expect(cliNotFound.exitCode).toBe(3); + expect(cliNotFound.error).toEqual(mcpNotFound.error); + + const cliConflict = await callCli(seedApplication().application, [ + 'task', + 'start', + 'task-1', + '--output', + 'json', + ]); + const mcpConflict = await callMcp(seedApplication().application, 'task_start', { + taskId: 'task-1', + }); + expect(cliConflict.exitCode).toBe(4); + expect(cliConflict.error).toEqual(mcpConflict.error); + }); +}); diff --git a/tests/unit/interfaces/cli/architecture.test.ts b/tests/unit/interfaces/cli/architecture.test.ts new file mode 100644 index 0000000..f930616 --- /dev/null +++ b/tests/unit/interfaces/cli/architecture.test.ts @@ -0,0 +1,23 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : []; + }); +} + +describe('CLI adapter boundaries', () => { + it('does not import from the MCP adapter namespace', () => { + const cliRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../../src/interfaces/cli', + ); + for (const file of sourceFiles(cliRoot)) { + expect(readFileSync(file, 'utf8'), file).not.toMatch(/interfaces[\\/]mcp|\.\.\/mcp/); + } + }); +}); diff --git a/tests/unit/interfaces/cli/cli-errors.test.ts b/tests/unit/interfaces/cli/cli-errors.test.ts new file mode 100644 index 0000000..18d2723 --- /dev/null +++ b/tests/unit/interfaces/cli/cli-errors.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + InvalidTaskRequestError, + TaskNotFoundError, + TaskPersistenceError, +} from '../../../../src/application/tasks/task-application-errors.js'; +import { TaskArchivedError, TaskTransitionError } from '../../../../src/domain/task/task-errors.js'; +import type { TaskApplication } from '../../../../src/application/tasks/task-application.js'; +import type { TaskRuntime } from '../../../../src/interfaces/shared/create-task-runtime.js'; +import { runCli } from '../../../../src/interfaces/cli/run-cli.js'; + +describe('CLI error envelopes and exit codes', () => { + it.each([ + ['invalid request', new InvalidTaskRequestError('private validation'), 2, 'VALIDATION_ERROR'], + ['not found', new TaskNotFoundError('private id'), 3, 'NOT_FOUND'], + ['archived', new TaskArchivedError('private archive'), 4, 'ARCHIVED_TASK'], + ['conflict', new TaskTransitionError('private transition'), 4, 'CONFLICT'], + ['storage', new TaskPersistenceError('private storage'), 5, 'STORAGE_ERROR'], + ['unexpected', new Error('private implementation'), 1, 'INTERNAL_ERROR'], + ] as const)('maps %s without leaking internals', async (_name, error, exitCode, code) => { + const stdout = { write: vi.fn() }; + const stderr = { write: vi.fn() }; + const runtime: TaskRuntime = { + taskApplication: { + get: vi.fn(() => { + throw error; + }), + } as unknown as TaskApplication, + close: vi.fn(), + }; + + await expect( + runCli(['task', 'get', 'task-1', '--output', 'json'], { + createRuntime: () => runtime, + stdout, + stderr, + }), + ).resolves.toBe(exitCode); + + expect(stdout.write).toHaveBeenCalledOnce(); + expect(stderr.write).toHaveBeenCalledOnce(); + expect(stdout.write.mock.calls[0]?.[0]).not.toContain('private'); + expect(stderr.write.mock.calls[0]?.[0]).not.toContain('private'); + expect(JSON.parse(stdout.write.mock.calls[0]?.[0] as string)).toMatchObject({ + ok: false, + error: { code }, + }); + expect(runtime.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/interfaces/cli/command-handlers.test.ts b/tests/unit/interfaces/cli/command-handlers.test.ts new file mode 100644 index 0000000..f499fde --- /dev/null +++ b/tests/unit/interfaces/cli/command-handlers.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TaskApplication } from '../../../../src/application/tasks/task-application.js'; +import type { Task } from '../../../../src/domain/task/task.js'; +import type { CliCommand } from '../../../../src/interfaces/cli/cli-command.js'; +import { executeCliCommand } from '../../../../src/interfaces/cli/execute-cli-command.js'; + +function task(overrides: Partial = {}): Task { + return { + id: 'task-1', + title: 'Prepare release', + description: null, + status: 'INBOX', + priority: null, + workspace: 'relay', + sourceContext: null, + createdByType: 'AGENT', + createdByName: 'Codex', + sessionId: 'session-a', + createdAt: '2026-07-26T10:00:00.000Z', + updatedAt: '2026-07-26T10:00:00.000Z', + startedAt: null, + completedAt: null, + archivedAt: null, + ...overrides, + }; +} + +function application(overrides: Record = {}): TaskApplication { + return { + create: vi.fn(() => task()), + get: vi.fn(() => task()), + list: vi.fn(() => [task()]), + findSimilar: vi.fn(() => []), + listSessionCaptures: vi.fn(() => [task()]), + edit: vi.fn(() => ({ before: task(), task: task({ title: 'Updated' }) })), + moveToInbox: vi.fn(() => ({ before: task(), task: task() })), + activate: vi.fn(() => ({ before: task(), task: task({ status: 'ACTIVE' }) })), + moveToBacklog: vi.fn(() => ({ before: task(), task: task({ status: 'BACKLOG' }) })), + start: vi.fn(() => ({ before: task(), task: task({ status: 'IN_PROGRESS' }) })), + complete: vi.fn(() => ({ before: task(), task: task({ status: 'DONE' }) })), + archive: vi.fn(() => ({ before: task(), task: task({ status: 'ARCHIVED' }) })), + ...overrides, + } as unknown as TaskApplication; +} + +describe('executeCliCommand', () => { + it('looks for duplicates before creating an AGENT capture and returns warnings', () => { + const findSimilar = vi.fn(() => [task({ id: 'existing' })]); + const create = vi.fn(() => task({ id: 'created' })); + const app = application({ findSimilar, create }); + + const result = executeCliCommand( + { + kind: 'task.capture', + title: 'Capture', + agent: 'Codex', + sessionId: 'session-a', + workspace: 'relay', + }, + app, + ); + + expect(findSimilar).toHaveBeenCalledWith({ title: 'Capture', workspace: 'relay', limit: 5 }); + expect(create).toHaveBeenCalledWith({ + title: 'Capture', + workspace: 'relay', + sessionId: 'session-a', + creator: { type: 'AGENT', name: 'Codex' }, + }); + expect(result).toMatchObject({ + data: { task: { id: 'created' }, change: { action: 'CREATED' } }, + warnings: [{ code: 'POSSIBLE_DUPLICATE', candidates: [{ id: 'existing' }] }], + }); + }); + + it('maps reads to their exact application requests', () => { + const app = application(); + executeCliCommand( + { kind: 'task.list', statuses: ['INBOX', 'DONE'], workspace: 'relay', limit: 10 }, + app, + ); + executeCliCommand({ kind: 'task.get', id: 'task-1' }, app); + executeCliCommand( + { kind: 'task.find-similar', title: 'Prepare release', workspace: 'relay', limit: 5 }, + app, + ); + executeCliCommand({ kind: 'session.captures', sessionId: 'session-a', limit: 100 }, app); + + expect(app.list).toHaveBeenCalledWith({ + statuses: ['INBOX', 'DONE'], + workspace: 'relay', + limit: 10, + }); + expect(app.get).toHaveBeenCalledWith({ id: 'task-1' }); + expect(app.findSimilar).toHaveBeenCalledWith({ + title: 'Prepare release', + workspace: 'relay', + limit: 5, + }); + expect(app.listSessionCaptures).toHaveBeenCalledWith({ sessionId: 'session-a', limit: 100 }); + }); + + it('maps edit, triage, and lifecycle commands to focused application calls', () => { + const app = application(); + executeCliCommand({ kind: 'task.edit', id: 'task-1', changes: { description: null } }, app); + executeCliCommand({ kind: 'task.triage', id: 'task-1', target: 'ACTIVE' }, app); + executeCliCommand({ kind: 'task.start', id: 'task-1', action: 'start' }, app); + executeCliCommand({ kind: 'task.complete', id: 'task-1', action: 'complete' }, app); + executeCliCommand({ kind: 'task.archive', id: 'task-1', action: 'archive' }, app); + + expect(app.edit).toHaveBeenCalledWith({ id: 'task-1', description: null }); + expect(app.activate).toHaveBeenCalledWith({ id: 'task-1' }); + expect(app.start).toHaveBeenCalledWith({ id: 'task-1' }); + expect(app.complete).toHaveBeenCalledWith({ id: 'task-1' }); + expect(app.archive).toHaveBeenCalledWith({ id: 'task-1' }); + }); + + it('returns exact change metadata for a no-op edit', () => { + const unchanged = task(); + const app = application({ edit: vi.fn(() => ({ before: unchanged, task: unchanged })) }); + const result = executeCliCommand( + { kind: 'task.edit', id: unchanged.id, changes: { title: unchanged.title } }, + app, + ); + expect(result.data.change).toEqual({ action: 'NO_CHANGE', fields: [] }); + }); + + it('accepts only validated command variants', () => { + const command: CliCommand = { kind: 'task.get', id: 'task-1' }; + expect(executeCliCommand(command, application()).data).toHaveProperty('task'); + }); +}); diff --git a/tests/unit/interfaces/cli/parse-cli.test.ts b/tests/unit/interfaces/cli/parse-cli.test.ts new file mode 100644 index 0000000..65119e8 --- /dev/null +++ b/tests/unit/interfaces/cli/parse-cli.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import { parseCli } from '../../../../src/interfaces/cli/parse-cli.js'; + +describe('parseCli', () => { + it.each([ + [ + 'task capture', + [ + 'task', + 'capture', + '--title', + 'Capture', + '--agent', + 'Codex', + '--session', + 's-1', + '--output', + 'json', + ], + ], + ['task list', ['task', 'list', '--output', 'json']], + ['task get', ['task', 'get', 'task-1', '--output', 'json']], + ['task find-similar', ['task', 'find-similar', '--title', 'Find', '--output', 'json']], + ['task edit', ['task', 'edit', 'task-1', '--title', 'Updated', '--output', 'json']], + ['task triage', ['task', 'triage', 'task-1', '--to', 'ACTIVE', '--output', 'json']], + ['task start', ['task', 'start', 'task-1', '--output', 'json']], + ['task complete', ['task', 'complete', 'task-1', '--output', 'json']], + ['task archive', ['task', 'archive', 'task-1', '--output', 'json']], + ['session captures', ['session', 'captures', '--session', 's-1', '--output', 'json']], + ])('parses the valid minimum invocation for %s', (_name, argv) => { + expect(() => parseCli(argv)).not.toThrow(); + }); + + it('normalizes and types every supported option', () => { + expect( + parseCli([ + 'task', + 'capture', + '--title', + ' Capture ', + '--description', + ' Details ', + '--priority', + 'HIGH', + '--workspace', + ' relay ', + '--source-context', + ' issue-22 ', + '--agent', + ' Codex ', + '--session', + ' s-1 ', + '--output', + 'json', + ]), + ).toEqual({ + kind: 'task.capture', + title: 'Capture', + description: 'Details', + priority: 'HIGH', + workspace: 'relay', + sourceContext: 'issue-22', + agent: 'Codex', + sessionId: 's-1', + }); + }); + + it('supports repeated statuses and maps defaults', () => { + expect( + parseCli([ + 'task', + 'list', + '--status', + 'INBOX', + '--status', + 'DONE', + '--limit', + '10', + '--output', + 'json', + ]), + ).toMatchObject({ kind: 'task.list', statuses: ['INBOX', 'DONE'], limit: 10 }); + expect(parseCli(['task', 'list', '--output', 'json'])).toMatchObject({ + kind: 'task.list', + statuses: ['INBOX', 'ACTIVE', 'IN_PROGRESS', 'BACKLOG', 'DONE', 'ARCHIVED'], + limit: 100, + }); + }); + + it('turns clear flags into nullable edit changes', () => { + expect( + parseCli([ + 'task', + 'edit', + 'task-1', + '--clear-description', + '--clear-priority', + '--output', + 'json', + ]), + ).toEqual({ + kind: 'task.edit', + id: 'task-1', + changes: { description: null, priority: null }, + }); + }); + + it.each([ + ['unknown command', ['task', 'unknown', '--output', 'json']], + [ + 'missing required option', + ['task', 'capture', '--agent', 'Codex', '--session', 's-1', '--output', 'json'], + ], + ['unknown option', ['task', 'get', 'task-1', '--bogus', 'value', '--output', 'json']], + ['missing option value', ['task', 'get', 'task-1', '--output']], + [ + 'duplicate singular option', + ['task', 'get', 'task-1', '--output', 'json', '--output', 'json'], + ], + ['unexpected positional argument', ['task', 'list', 'extra', '--output', 'json']], + ['invalid status', ['task', 'list', '--status', 'BROKEN', '--output', 'json']], + [ + 'invalid priority', + [ + 'task', + 'capture', + '--title', + 'Task', + '--agent', + 'Codex', + '--session', + 's-1', + '--priority', + 'URGENT', + '--output', + 'json', + ], + ], + ['invalid limit', ['task', 'list', '--limit', '101', '--output', 'json']], + [ + 'edit clear/value conflict', + [ + 'task', + 'edit', + 'task-1', + '--description', + 'text', + '--clear-description', + '--output', + 'json', + ], + ], + ['edit with no operation', ['task', 'edit', 'task-1', '--output', 'json']], + ['non-json output', ['task', 'get', 'task-1', '--output', 'text']], + ])('rejects %s', (_name, argv) => { + expect(() => parseCli(argv)).toThrow(); + }); +}); diff --git a/tests/unit/interfaces/cli/run-cli.test.ts b/tests/unit/interfaces/cli/run-cli.test.ts index f7d9f58..114eb9a 100644 --- a/tests/unit/interfaces/cli/run-cli.test.ts +++ b/tests/unit/interfaces/cli/run-cli.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { TaskApplication } from '../../../../src/application/tasks/task-application.js'; +import { TaskNotFoundError } from '../../../../src/application/tasks/task-application-errors.js'; import type { TaskRuntime } from '../../../../src/interfaces/shared/create-task-runtime.js'; import { runCli } from '../../../../src/interfaces/cli/run-cli.js'; @@ -82,4 +83,83 @@ describe('runCli', () => { }, ]); }); + + it('emits one internal envelope when cleanup fails after success', async () => { + const stdout = { write: vi.fn() }; + const stderr = { write: vi.fn() }; + const runtime: TaskRuntime = { + taskApplication: { + get: vi.fn(() => ({ id: 'task-1', title: 'Task' })), + } as unknown as TaskApplication, + close: vi.fn(() => { + throw new Error('close failed'); + }), + }; + + await expect( + runCli(['task', 'get', 'task-1', '--output', 'json'], { + createRuntime: () => runtime, + stdout, + stderr, + }), + ).resolves.toBe(1); + + expect(stdout.write).toHaveBeenCalledOnce(); + expect(JSON.parse(stdout.write.mock.calls[0]?.[0] as string)).toMatchObject({ + ok: false, + error: { code: 'INTERNAL_ERROR' }, + }); + expect(runtime.close).toHaveBeenCalledOnce(); + }); + + it('preserves the command error and still emits one envelope when cleanup also fails', async () => { + const stdout = { write: vi.fn() }; + const stderr = { write: vi.fn() }; + const runtime: TaskRuntime = { + taskApplication: { + get: vi.fn(() => { + throw new TaskNotFoundError('private id'); + }), + } as unknown as TaskApplication, + close: vi.fn(() => { + throw new Error('close failed'); + }), + }; + + await expect( + runCli(['task', 'get', 'task-1', '--output', 'json'], { + createRuntime: () => runtime, + stdout, + stderr, + }), + ).resolves.toBe(3); + + expect(stdout.write).toHaveBeenCalledOnce(); + expect(JSON.parse(stdout.write.mock.calls[0]?.[0] as string)).toMatchObject({ + ok: false, + error: { code: 'NOT_FOUND' }, + }); + expect(stderr.write).toHaveBeenCalledOnce(); + expect(runtime.close).toHaveBeenCalledOnce(); + }); + + it('does not close or write twice when runtime creation fails', async () => { + const stdout = { write: vi.fn() }; + const stderr = { write: vi.fn() }; + const createRuntime = vi.fn(() => { + throw new Error('startup failed'); + }); + + await expect( + runCli(['task', 'get', 'task-1', '--output', 'json'], { + createRuntime, + stdout, + stderr, + }), + ).resolves.toBe(5); + + expect(createRuntime).toHaveBeenCalledOnce(); + expect(stdout.write).toHaveBeenCalledOnce(); + expect(stderr.write).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/unit/scripts/validate-repository-assets.test.ts b/tests/unit/scripts/validate-repository-assets.test.ts index 0dcf6bc..af8f68b 100644 --- a/tests/unit/scripts/validate-repository-assets.test.ts +++ b/tests/unit/scripts/validate-repository-assets.test.ts @@ -12,6 +12,7 @@ function createFixtureRoot(): string { 'src/database', 'src/interfaces/mcp', 'src/interfaces/http', + 'src/interfaces/cli', 'web/src', ]; @@ -25,24 +26,32 @@ function createFixtureRoot(): string { writeFileSync(join(rootDir, '.prettierrc.json'), '{}\n'); writeFileSync(join(rootDir, 'eslint.config.js'), 'export default [];\n'); writeFileSync(join(rootDir, 'tsconfig.base.json'), '{}\n'); + writeFileSync( + join(rootDir, 'tsup.config.ts'), + "export default { entry: { 'cli/main': 'src/interfaces/cli/main.ts' } };\n", + ); writeFileSync( join(rootDir, 'package.json'), JSON.stringify({ name: 'relay', version: '0.1.0', bin: { + relay: './dist/cli/main.js', 'relay-mcp': './dist/mcp/main.js', }, }), ); writeFileSync( join(rootDir, 'README.md'), - '# Relay\n\n[Decision](docs/decisions/0001-product-and-architecture.md)\n', + '# Relay\n\n[Decision](docs/decisions/0001-product-and-architecture.md)\n\n`node dist/cli/main.js`\n', ); writeFileSync(join(rootDir, 'src/application/health/get-health.ts'), 'export {};\n'); writeFileSync(join(rootDir, 'src/database/connection.ts'), 'export {};\n'); writeFileSync(join(rootDir, 'src/interfaces/mcp/create-mcp-server.ts'), 'export {};\n'); writeFileSync(join(rootDir, 'src/interfaces/http/create-http-server.ts'), 'export {};\n'); + writeFileSync(join(rootDir, 'src/interfaces/cli/main.ts'), 'export {};\n'); + writeFileSync(join(rootDir, 'src/interfaces/cli/run-cli.ts'), 'export {};\n'); + writeFileSync(join(rootDir, 'src/interfaces/cli/parse-cli.ts'), 'export {};\n'); writeFileSync(join(rootDir, 'web/src/App.tsx'), 'export function App() { return null; }\n'); mkdirSync(join(rootDir, 'docs/decisions'), { recursive: true }); writeFileSync(join(rootDir, 'docs/decisions/0001-product-and-architecture.md'), '# decision\n'); @@ -51,7 +60,10 @@ function createFixtureRoot(): string { '# decision\n', ); writeFileSync(join(rootDir, 'docs/mcp-tools.md'), '# MCP tools\n'); - writeFileSync(join(rootDir, 'docs/cli-reference.md'), '# CLI reference\n'); + writeFileSync( + join(rootDir, 'docs/cli-reference.md'), + '# CLI reference\n\n`node dist/cli/main.js`\n', + ); writeFileSync(join(rootDir, 'docs/session-semantics.md'), '# Session semantics\n'); mkdirSync(join(rootDir, 'src/interfaces/contracts'), { recursive: true }); for (const filename of [ @@ -77,6 +89,8 @@ function createFixtureRoot(): string { } mkdirSync(join(rootDir, 'dist/mcp'), { recursive: true }); writeFileSync(join(rootDir, 'dist/mcp/main.js'), 'console.log("ok");\n'); + mkdirSync(join(rootDir, 'dist/cli'), { recursive: true }); + writeFileSync(join(rootDir, 'dist/cli/main.js'), 'console.log("ok");\n'); return rootDir; } @@ -128,4 +142,12 @@ describe('validateRepositoryAssets', () => { expect(() => validateRepositoryAssets({ rootDir })).toThrow(/agent-integration|mcp-tools/i); }); + + it('requires the source-checkout CLI entry and matching built bin', () => { + const rootDir = createFixtureRoot(); + createdRoots.push(rootDir); + rmSync(join(rootDir, 'dist/cli/main.js')); + + expect(() => validateRepositoryAssets({ rootDir })).toThrow(/CLI executable|dist\/cli/i); + }); }); From 2b411009d1a8e2e92696196315557703650b5933 Mon Sep 17 00:00:00 2001 From: Krishnamurti Pandaram Date: Tue, 28 Jul 2026 00:46:46 +0530 Subject: [PATCH 4/4] Address CLI review feedback --- README.md | 2 +- .../2026-07-27-issue-22-cli-task-adapter.md | 39 ++++++++++++++++--- src/interfaces/cli/cli-command.ts | 9 ++--- src/interfaces/cli/main.ts | 1 + src/interfaces/cli/parse-cli.ts | 34 +++++++++++++--- tests/integration/mcp-cli-parity.test.ts | 20 +++++++--- 6 files changed, 82 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8d84371..02671e1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ To run the MCP server after building: node dist/mcp/main.js ``` -To use the source-checkout CLI from any working directory: +To use the source-checkout CLI from any working directory, run `pnpm build:node` from the repository checkout root first (or use `pnpm --dir /absolute/path/to/relay build:node` from another working directory): ```bash pnpm build:node diff --git a/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md b/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md index 2813936..a06bee2 100644 --- a/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md +++ b/docs/superpowers/plans/2026-07-27-issue-22-cli-task-adapter.md @@ -62,15 +62,42 @@ export async function runCli( argv: readonly string[], dependencies: CliDependencies, ): Promise { - const command = parseCli(argv); - const runtime = dependencies.createRuntime(); + let command; try { - return execute(command, runtime.taskApplication, dependencies); + command = parseCli(argv); } catch (error) { - return writeCliError(error, dependencies.stderr, dependencies.stdout); - } finally { + return writeError(error, dependencies); + } + + let runtime: TaskRuntime; + try { + runtime = dependencies.createRuntime(); + } catch (error) { + return writeError(error, dependencies, { runtimeCreation: true }); + } + + let result; + let executionFailed = false; + let executionError; + try { + result = executeCliCommand(command, runtime.taskApplication); + } catch (error) { + executionFailed = true; + executionError = error; + } + let cleanupFailed = false; + let cleanupError; + try { runtime.close(); + } catch (error) { + cleanupFailed = true; + cleanupError = error; } + + if (executionFailed) return writeError(executionError, dependencies); + if (cleanupFailed) return writeError(cleanupError, dependencies); + write(dependencies.stdout, cliSuccess(result.data, result.warnings ?? [])); + return 0; } ``` @@ -149,7 +176,7 @@ Run: `pnpm test -- tests/unit/interfaces/cli/run-cli.test.ts` **Files:** -- Create: `src/interfaces/cli/commands/task-edit.ts`, `src/interfaces/cli/commands/task-triage.ts`, `src/interfaces/cli/commands/task-start.ts`, `src/interfaces/cli/commands/task-complete.ts`, `src/interfaces/cli/commands/task-archive.ts` +- Create: `src/interfaces/cli/commands/task-edit.ts`, `src/interfaces/cli/commands/task-triage.ts`, `src/interfaces/cli/commands/task-lifecycle.ts` - Modify: `src/interfaces/cli/run-cli.ts`, `tests/unit/interfaces/cli/run-cli.test.ts` **Interfaces:** diff --git a/src/interfaces/cli/cli-command.ts b/src/interfaces/cli/cli-command.ts index ab1b394..5df2b64 100644 --- a/src/interfaces/cli/cli-command.ts +++ b/src/interfaces/cli/cli-command.ts @@ -44,11 +44,10 @@ export interface TaskTriageCommand { readonly target: 'INBOX' | 'ACTIVE' | 'BACKLOG'; } -export interface TaskLifecycleCommand { - readonly kind: 'task.start' | 'task.complete' | 'task.archive'; - readonly id: string; - readonly action: 'start' | 'complete' | 'archive'; -} +export type TaskLifecycleCommand = + | { readonly kind: 'task.start'; readonly id: string; readonly action: 'start' } + | { readonly kind: 'task.complete'; readonly id: string; readonly action: 'complete' } + | { readonly kind: 'task.archive'; readonly id: string; readonly action: 'archive' }; export interface SessionCapturesCommand { readonly kind: 'session.captures'; diff --git a/src/interfaces/cli/main.ts b/src/interfaces/cli/main.ts index 4e3ea69..be75709 100644 --- a/src/interfaces/cli/main.ts +++ b/src/interfaces/cli/main.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node import { createTaskRuntime } from '../shared/create-task-runtime.js'; import { runCli } from './run-cli.js'; diff --git a/src/interfaces/cli/parse-cli.ts b/src/interfaces/cli/parse-cli.ts index 233b743..e62c3be 100644 --- a/src/interfaces/cli/parse-cli.ts +++ b/src/interfaces/cli/parse-cli.ts @@ -101,9 +101,11 @@ export function parseCli(argv: readonly string[]): CliCommand { case 'triage': return parseTaskTriage(id, tokens); case 'start': + return parseTaskLifecycle('start', id, tokens); case 'complete': + return parseTaskLifecycle('complete', id, tokens); case 'archive': - return parseTaskLifecycle(action, id, tokens); + return parseTaskLifecycle('archive', id, tokens); default: throw new CliUsageError(`Unknown command: task ${action}.`); } @@ -204,6 +206,21 @@ function parseTaskTriage(id: string | undefined, tokens: readonly string[]): Tas }; } +function parseTaskLifecycle( + action: 'start', + id: string | undefined, + tokens: readonly string[], +): Extract; +function parseTaskLifecycle( + action: 'complete', + id: string | undefined, + tokens: readonly string[], +): Extract; +function parseTaskLifecycle( + action: 'archive', + id: string | undefined, + tokens: readonly string[], +): Extract; function parseTaskLifecycle( action: TaskLifecycleCommand['action'], id: string | undefined, @@ -212,7 +229,14 @@ function parseTaskLifecycle( const taskId = requiredId(id); const options = parseOptions(tokens, lifecycleOptions); requireJsonOutput(options); - return { kind: `task.${action}`, action, id: taskId }; + switch (action) { + case 'start': + return { kind: 'task.start', action, id: taskId }; + case 'complete': + return { kind: 'task.complete', action, id: taskId }; + case 'archive': + return { kind: 'task.archive', action, id: taskId }; + } } function parseSessionCaptures(tokens: readonly string[]): SessionCapturesCommand { @@ -281,10 +305,8 @@ function requiredSession(options: ReadonlyMap, key: s } function readId(value: string | undefined, label: string): string | undefined { - if (value === undefined || value.startsWith('--')) { - if (value?.startsWith('--')) throw new CliUsageError(`A ${label} is required.`); - return undefined; - } + if (value === undefined) return undefined; + if (value.startsWith('--')) throw new CliUsageError(`A ${label} is required.`); return boundedText(value, label, MAX_ID_LENGTH); } diff --git a/tests/integration/mcp-cli-parity.test.ts b/tests/integration/mcp-cli-parity.test.ts index d0be043..c2b2906 100644 --- a/tests/integration/mcp-cli-parity.test.ts +++ b/tests/integration/mcp-cli-parity.test.ts @@ -42,11 +42,11 @@ async function callMcp( application: TaskApplication, name: string, arguments_: Record, -): Promise<{ data?: unknown; error?: unknown }> { +): Promise<{ data?: unknown; warnings?: readonly unknown[]; error?: unknown }> { const { client, close } = await connectMcp(createMcpServer(application)); try { const result = (await client.callTool({ name, arguments: arguments_ })) as { - structuredContent?: { data?: unknown; error?: unknown }; + structuredContent?: { data?: unknown; warnings?: readonly unknown[]; error?: unknown }; }; return result.structuredContent ?? {}; } finally { @@ -61,14 +61,23 @@ async function callCli(application: TaskApplication, argv: readonly string[]) { const exitCode = await runCli(argv, { createRuntime: () => runtime, stdout, stderr }); const envelope = JSON.parse(stdout.write.mock.calls[0]?.[0] as string) as { data?: unknown; + warnings?: readonly unknown[]; error?: unknown; }; - return { exitCode, data: envelope.data, error: envelope.error, stderr }; + return { + exitCode, + data: envelope.data, + warnings: envelope.warnings, + error: envelope.error, + stderr, + }; } describe('MCP and CLI semantic parity', () => { it('matches capture payloads and duplicate warnings', async () => { - const cli = await callCli(createApplication(), [ + const cliFixture = seedApplication(); + const mcpFixture = seedApplication(); + const cli = await callCli(cliFixture.application, [ 'task', 'capture', '--title', @@ -82,7 +91,7 @@ describe('MCP and CLI semantic parity', () => { '--output', 'json', ]); - const mcp = await callMcp(createApplication(), 'task_capture', { + const mcp = await callMcp(mcpFixture.application, 'task_capture', { title: 'Prepare release', createdByName: 'Codex', sessionId: 'session-a', @@ -90,6 +99,7 @@ describe('MCP and CLI semantic parity', () => { }); expect(cli.exitCode).toBe(0); expect(cli.data).toEqual(mcp.data); + expect(cli.warnings).toEqual(mcp.warnings); expect(cli.data).toMatchObject({ change: { action: 'CREATED' }, task: { id: 'task-1' } }); expect(cli.data).toHaveProperty('task'); });