diff --git a/.gitignore b/.gitignore index a1127ae..f264915 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ *.tgz .DS_Store +/worktrees/ +/tmp/ diff --git a/README.md b/README.md index b7f3f57..ae9f870 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,39 @@ Returns `{id, filePath}`. Moves the file from `agents-locks/` to `agents-locks/done/`, sets `status: done`. Errors clearly (not silently) if the lock doesn't exist, or already exists but is already done. +## CLI usage + +The exact same lock store the 5 MCP tools above talk to is also reachable from a plain terminal or a shell script — useful for a human checking coordination state directly, or for any agent harness that can run a command but doesn't (yet) speak MCP. + +`index.js` dispatches on `argv`: called with **no arguments** (or the explicit `serve` alias) it starts the MCP stdio server exactly as before — every existing MCP client config keeps working unchanged. Called with any other first argument, it runs as a CLI and exits with a real exit code (0 on success, 1 on a usage error or a store error like a missing lock id) instead of hanging waiting for JSON-RPC on stdin. + +```bash +# Human-readable summary of active locks in the current repo (or worktree) +agent-locks status + +# Full query, same filters as lock_query, --json for scripting +agent-locks list [--status active|done|all] [--scope ] [--agent ] [--text ] [--json] + +# Same as lock_check_conflict — informational only, exit code is always 0 +agent-locks check + +# Same as lock_create +agent-locks claim --title --scope [--scope ...] [--task ...] [--agent ] [--parent ] + +# Same as lock_update +agent-locks update --task [--done | --undone] [--note ] + +# Same as lock_finish +agent-locks finish [--summary ] + +# Explicit alias for "no arguments" — starts the MCP server +agent-locks serve +``` + +Every subcommand resolves `locksRoot` fresh via `resolveLocksRoot()`, the same as every MCP tool handler — running the CLI from one worktree while an agent's MCP session is live in another worktree of the same repo still coordinates correctly, for the same git-common-dir reason the whole tool exists. + +No new dependency was added for this — argument parsing is hand-rolled (`src/cli.ts`) to match the project's existing minimal footprint. + ## Honest `agent_id` / `parent_agent_id` semantics **Claude Code does not expose any session id to a stdio MCP server subprocess** — not via environment variable, not via any MCP `initialize` parameter (the spec's `initialize` params are only `protocolVersion`, `capabilities`, `clientInfo`), and there is no documented mechanism for a subagent's MCP server process to learn its parent session's id either. diff --git a/dist/index.js b/dist/index.js index a10b6aa..d039ecb 100755 --- a/dist/index.js +++ b/dist/index.js @@ -494,14 +494,266 @@ function createServer() { return server; } +// src/cli.ts +var USAGE = `agent-locks \u2014 filesystem-based work-claiming locks for AI coding agents, shared across every git worktree of the current repository. + +Usage: + agent-locks Start the MCP stdio server (same as running with no args \u2014 this is what an MCP client config should use). + agent-locks serve Same as above, explicit. + agent-locks status Human-readable summary of active locks. + agent-locks list [options] List locks. See "agent-locks list --help". + agent-locks check Check whether any active lock overlaps the given glob(s). Informational only \u2014 exits 0 either way. + agent-locks claim [options] Create a new lock. See "agent-locks claim --help". + agent-locks update [options] Mark a task done/undone on an existing lock. See "agent-locks update --help". + agent-locks finish [--summary ] Mark a lock done and archive it. + agent-locks --help Show this message. + +Every subcommand talks to the exact same lock store the MCP tools use \u2014 a human running "agent-locks status" and an agent calling lock_query see identical, live state.`; +var LIST_USAGE = `agent-locks list [options] + +Options: + --status Which locks to include. Default: active. + --scope Only locks whose scope overlaps this glob. Repeatable. + --agent Only locks with this exact agent_id. + --text Case-insensitive substring search over title + notes. + --json Print raw JSON instead of a formatted table.`; +var CLAIM_USAGE = `agent-locks claim [options] + +Options: + --title Required. Short description of the work. + --scope Required. Glob pattern this lock claims. Repeatable. + --task A task to track on this lock. Repeatable; order preserved. + --agent Your own agent id, if you have one. Never fabricated if omitted. + --parent Your parent agent's id, if known. + --json Print raw JSON instead of a short confirmation line.`; +var UPDATE_USAGE = `agent-locks update [options] + +Options: + --task Required. Must match an existing task's text exactly. + --done Mark the task done (default if neither --done nor --undone given). + --undone Mark the task not done. + --note Append a free-text note to the lock. + --json Print raw JSON instead of a short confirmation line.`; +var CliUsageError = class extends Error { +}; +var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["--json", "--done", "--undone", "--help"]); +function parseArgs(argv) { + const positionals = []; + const flags = /* @__PURE__ */ new Map(); + const boolFlags = /* @__PURE__ */ new Set(); + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + if (BOOLEAN_FLAGS.has(arg)) { + boolFlags.add(arg); + continue; + } + const value = argv[i + 1]; + if (value === void 0 || value.startsWith("--")) { + throw new CliUsageError(`Flag ${arg} requires a value.`); + } + const existing = flags.get(arg) ?? []; + existing.push(value); + flags.set(arg, existing); + i += 1; + } + return { positionals, flags, boolFlags }; +} +function oneOf(flags, name) { + const values = flags.get(name); + if (values === void 0) return void 0; + return values[values.length - 1]; +} +function allOf(flags, name) { + return flags.get(name) ?? []; +} +function formatLockTable(locks) { + if (locks.length === 0) return "(no locks)"; + const rows = locks.map((lock) => [ + lock.id, + lock.status, + `${lock.percentComplete}%`, + lock.agent_id ?? "(unknown agent)", + lock.scope.join(", "), + lock.title + ]); + const header = ["ID", "STATUS", "DONE", "AGENT", "SCOPE", "TITLE"]; + const widths = header.map((h, col) => Math.max(h.length, ...rows.map((r) => r[col].length))); + const formatRow = (row) => row.map((cell, col) => cell.padEnd(widths[col])).join(" "); + return [formatRow(header), formatRow(header.map((h) => "-".repeat(h.length))), ...rows.map(formatRow)].join("\n"); +} +async function cmdStatus() { + const locksRoot = await resolveLocksRoot(); + const locks = await queryLocks(locksRoot, {}); + console.log(`agent-locks: ${locks.length} active lock(s) in ${locksRoot} +`); + console.log(formatLockTable(locks)); +} +async function cmdList(flags) { + if (flags.boolFlags.has("--help")) { + console.log(LIST_USAGE); + return; + } + const status = oneOf(flags.flags, "--status"); + if (status !== void 0 && !["active", "done", "all"].includes(status)) { + throw new CliUsageError(`--status must be one of active, done, all (got "${status}").`); + } + const scope = allOf(flags.flags, "--scope"); + const agent_id = oneOf(flags.flags, "--agent"); + const text = oneOf(flags.flags, "--text"); + const locksRoot = await resolveLocksRoot(); + const locks = await queryLocks(locksRoot, { + status, + scope: scope.length > 0 ? scope : void 0, + agent_id, + text + }); + if (flags.boolFlags.has("--json")) { + console.log(JSON.stringify(locks, null, 2)); + } else { + console.log(formatLockTable(locks)); + } +} +async function cmdCheck(flags) { + const scope = flags.positionals; + if (scope.length === 0) { + throw new CliUsageError('agent-locks check requires at least one scope glob, e.g. "agent-locks check src/auth/**".'); + } + const locksRoot = await resolveLocksRoot(); + const conflicts = await checkConflicts(locksRoot, scope); + if (flags.boolFlags.has("--json")) { + console.log(JSON.stringify(conflicts, null, 2)); + return; + } + if (conflicts.length === 0) { + console.log(`No active locks overlap ${scope.join(", ")}.`); + return; + } + console.log(`${conflicts.length} active lock(s) overlap ${scope.join(", ")} \u2014 informational only, nothing is blocked: +`); + console.log(formatLockTable(conflicts)); +} +async function cmdClaim(flags) { + if (flags.boolFlags.has("--help")) { + console.log(CLAIM_USAGE); + return; + } + const title = oneOf(flags.flags, "--title"); + if (!title) throw new CliUsageError('agent-locks claim requires --title. See "agent-locks claim --help".'); + const scope = allOf(flags.flags, "--scope"); + if (scope.length === 0) throw new CliUsageError('agent-locks claim requires at least one --scope. See "agent-locks claim --help".'); + const tasks = allOf(flags.flags, "--task"); + const agent_id = oneOf(flags.flags, "--agent") ?? null; + const parent_agent_id = oneOf(flags.flags, "--parent") ?? null; + const locksRoot = await resolveLocksRoot(); + const result = await createLock(locksRoot, { title, scope, tasks, agent_id, parent_agent_id }); + if (flags.boolFlags.has("--json")) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Claimed "${title}" as lock ${result.id}`); + } +} +async function cmdUpdate(flags) { + if (flags.boolFlags.has("--help")) { + console.log(UPDATE_USAGE); + return; + } + const lockId = flags.positionals[0]; + if (!lockId) throw new CliUsageError('agent-locks update requires a lock id as its first argument. See "agent-locks update --help".'); + const taskText = oneOf(flags.flags, "--task"); + if (!taskText) throw new CliUsageError('agent-locks update requires --task. See "agent-locks update --help".'); + if (flags.boolFlags.has("--done") && flags.boolFlags.has("--undone")) { + throw new CliUsageError("Pass at most one of --done / --undone."); + } + const done = !flags.boolFlags.has("--undone"); + const note = oneOf(flags.flags, "--note"); + const locksRoot = await resolveLocksRoot(); + const result = await updateLock(locksRoot, { lock_id: lockId, task_text: taskText, done, note }); + if (flags.boolFlags.has("--json")) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Lock ${result.id}: "${taskText}" marked ${done ? "done" : "not done"} (${result.percentComplete}% complete overall).`); + } +} +async function cmdFinish(flags) { + const lockId = flags.positionals[0]; + if (!lockId) throw new CliUsageError("agent-locks finish requires a lock id as its first argument."); + const summary = oneOf(flags.flags, "--summary"); + const locksRoot = await resolveLocksRoot(); + const result = await finishLock(locksRoot, { lock_id: lockId, summary }); + if (flags.boolFlags.has("--json")) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Lock ${result.id} finished and archived.`); + } +} +async function runCli(argv) { + const [command, ...rest] = argv; + if (command === void 0 || command === "--help" || command === "-h") { + console.log(USAGE); + return 0; + } + try { + switch (command) { + case "status": + await cmdStatus(); + return 0; + case "list": + await cmdList(parseArgs(rest)); + return 0; + case "check": + await cmdCheck(parseArgs(rest)); + return 0; + case "claim": + await cmdClaim(parseArgs(rest)); + return 0; + case "update": + await cmdUpdate(parseArgs(rest)); + return 0; + case "finish": + await cmdFinish(parseArgs(rest)); + return 0; + default: + console.error(`agent-locks: unknown command "${command}". +`); + console.error(USAGE); + return 1; + } + } catch (error) { + if (error instanceof CliUsageError) { + console.error(`agent-locks: ${error.message}`); + return 1; + } + if (error instanceof NotAGitRepoError || error instanceof LockNotFoundError || error instanceof TaskNotFoundError || error instanceof LockNotActiveError) { + console.error(`agent-locks: ${error.message}`); + return 1; + } + console.error(`agent-locks: unexpected error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + return 1; + } +} + // src/index.ts -async function main() { +async function runServer() { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); } +async function main() { + const argv = process.argv.slice(2); + const isServerMode = argv.length === 0 || argv[0] === "serve"; + if (isServerMode) { + await runServer(); + return; + } + const exitCode = await runCli(argv); + process.exitCode = exitCode; +} main().catch((error) => { - process.stderr.write(`agent-locks: fatal error during startup: ${error instanceof Error ? error.stack ?? error.message : String(error)} + process.stderr.write(`agent-locks: fatal error: ${error instanceof Error ? error.stack ?? error.message : String(error)} `); process.exitCode = 1; }); diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..3ef9e64 --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,194 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { runCli } from '../cli.js'; + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }); + return stdout.trim(); +} + +let sandbox: string; +let repo: string; + +beforeEach(async () => { + sandbox = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-locks-cli-test-')); + repo = path.join(sandbox, 'repo'); + await fs.mkdir(repo, { recursive: true }); + await git(repo, ['init', '-q', '-b', 'main', '.']); + await git(repo, ['config', 'user.email', 'test@test.com']); + await git(repo, ['config', 'user.name', 'test']); + await fs.writeFile(path.join(repo, 'a.txt'), 'hi\n'); + await git(repo, ['add', 'a.txt']); + await git(repo, ['commit', '-q', '-m', 'init']); +}); + +afterEach(async () => { + await fs.rm(sandbox, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** Runs runCli with process.cwd() pointed at `repo` for the duration of the call, restoring it after. */ +async function runCliIn(repoDir: string, argv: string[]): Promise { + const originalCwd = process.cwd(); + process.chdir(repoDir); + try { + return await runCli(argv); + } finally { + process.chdir(originalCwd); + } +} + +function captureConsole(): { logs: string[]; errors: string[] } { + const logs: string[] = []; + const errors: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + return { logs, errors }; +} + +describe('runCli', () => { + it('prints usage and exits 0 for --help and for no command', async () => { + const { logs } = captureConsole(); + expect(await runCliIn(repo, ['--help'])).toBe(0); + expect(await runCliIn(repo, [])).toBe(0); + expect(logs.every((line) => line.includes('agent-locks'))).toBe(true); + }); + + it('exits 1 with a clear message for an unknown command', async () => { + const { errors } = captureConsole(); + const code = await runCliIn(repo, ['bogus']); + expect(code).toBe(1); + expect(errors[0]).toContain('unknown command "bogus"'); + }); + + it('claim creates a lock and status/list/check see it', async () => { + const { logs } = captureConsole(); + + const claimCode = await runCliIn(repo, [ + 'claim', + '--title', 'Refactor auth', + '--scope', 'src/auth/**', + '--task', 'Write tests', + '--task', 'Update docs', + '--agent', 'claude-code', + ]); + expect(claimCode).toBe(0); + expect(logs[logs.length - 1]).toMatch(/^Claimed "Refactor auth" as lock \S+$/); + const lockId = logs[logs.length - 1].split(' ').pop() as string; + + logs.length = 0; + expect(await runCliIn(repo, ['status'])).toBe(0); + expect(logs.join('\n')).toContain('1 active lock'); + expect(logs.join('\n')).toContain('Refactor auth'); + + logs.length = 0; + expect(await runCliIn(repo, ['list', '--json'])).toBe(0); + const listed = JSON.parse(logs[0]); + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ id: lockId, title: 'Refactor auth', percentComplete: 0, agent_id: 'claude-code' }); + + logs.length = 0; + expect(await runCliIn(repo, ['check', 'src/auth/login.ts'])).toBe(0); + expect(logs.join('\n')).toContain('1 active lock(s) overlap'); + + logs.length = 0; + expect(await runCliIn(repo, ['check', 'src/unrelated/**'])).toBe(0); + expect(logs.join('\n')).toContain('No active locks overlap'); + + logs.length = 0; + expect(await runCliIn(repo, ['update', lockId, '--task', 'Write tests', '--done'])).toBe(0); + expect(logs[0]).toContain('"Write tests" marked done'); + expect(logs[0]).toContain('50% complete'); + + logs.length = 0; + expect(await runCliIn(repo, ['finish', lockId, '--summary', 'Shipped in #42'])).toBe(0); + expect(logs[0]).toBe(`Lock ${lockId} finished and archived.`); + + logs.length = 0; + expect(await runCliIn(repo, ['status'])).toBe(0); + expect(logs.join('\n')).toContain('0 active lock'); + }); + + it('claim requires --title and --scope, with a clear exit-1 error rather than a stack trace', async () => { + const { errors } = captureConsole(); + + expect(await runCliIn(repo, ['claim', '--scope', 'src/**'])).toBe(1); + expect(errors[0]).toContain('requires --title'); + + errors.length = 0; + expect(await runCliIn(repo, ['claim', '--title', 'x'])).toBe(1); + expect(errors[0]).toContain('requires at least one --scope'); + }); + + it('update on a non-existent lock id exits 1 with the store\'s own error message, not a stack trace', async () => { + const { errors } = captureConsole(); + const code = await runCliIn(repo, ['update', 'nonexistent-lock', '--task', 'x']); + expect(code).toBe(1); + expect(errors[0]).toContain('No lock found with id "nonexistent-lock"'); + }); + + it('update with a task_text that does not match exactly exits 1 listing the real available tasks', async () => { + const { logs, errors } = captureConsole(); + await runCliIn(repo, ['claim', '--title', 'x', '--scope', 'a/**', '--task', 'Do the thing']); + const lockId = logs[logs.length - 1].split(' ').pop() as string; + + const code = await runCliIn(repo, ['update', lockId, '--task', 'wrong text']); + expect(code).toBe(1); + expect(errors[0]).toContain('Do the thing'); + }); + + it('rejects passing both --done and --undone to update', async () => { + const { logs, errors } = captureConsole(); + await runCliIn(repo, ['claim', '--title', 'x', '--scope', 'a/**', '--task', 'y']); + const lockId = logs[logs.length - 1].split(' ').pop() as string; + + const code = await runCliIn(repo, ['update', lockId, '--task', 'y', '--done', '--undone']); + expect(code).toBe(1); + expect(errors[0]).toContain('at most one of --done / --undone'); + }); + + it('a flag requiring a value with none provided is a usage error, not a crash', async () => { + const { errors } = captureConsole(); + const code = await runCliIn(repo, ['claim', '--title']); + expect(code).toBe(1); + expect(errors[0]).toContain('--title requires a value'); + }); + + it('list --status rejects an invalid value rather than silently ignoring it', async () => { + const { errors } = captureConsole(); + const code = await runCliIn(repo, ['list', '--status', 'bogus']); + expect(code).toBe(1); + expect(errors[0]).toContain('--status must be one of active, done, all'); + }); + + it('status run outside a git repository exits 1 with NotAGitRepoError\'s message, not a stack trace', async () => { + const { errors } = captureConsole(); + const notARepo = path.join(sandbox, 'not-a-repo'); + await fs.mkdir(notARepo, { recursive: true }); + const code = await runCliIn(notARepo, ['status']); + expect(code).toBe(1); + expect(errors[0]).toContain('does not appear to be inside a git repository'); + }); +}); + +describe('CLI dispatch via the actual compiled binary', () => { + it('running dist/index.js with a CLI subcommand exits with the CLI\'s code, not the MCP server', async () => { + // Complements e2e.test.ts, which already proves argv.length === 0 starts + // the MCP server. This proves the OTHER branch of the same dispatch: a + // real subprocess spawn of the compiled artifact with a subcommand runs + // the CLI and exits, rather than hanging waiting for stdio JSON-RPC. + const distPath = path.resolve(import.meta.dirname, '../../dist/index.js'); + const { stdout } = await execFileAsync('node', [distPath, 'status'], { cwd: repo }); + expect(stdout).toContain('0 active lock'); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c8e50d3 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,318 @@ +/** + * Module: human-facing CLI for agent-locks. + * + * The MCP tools in server.ts are the primary interface (an agent talking to + * this server over stdio JSON-RPC); this module is a thin, dependency-free + * wrapper around the exact same lock/store.ts functions, for two audiences: + * + * 1. A human at a terminal who wants to see or manage lock state directly + * (`agent-locks status`, `agent-locks list`, ...) without going through + * an agent. + * 2. Any agent harness that can run a shell command but does not (or does + * not yet) speak MCP — the same coordination guarantees this tool + * provides are available via a plain subprocess call and exit code. + * + * No new dependency was added for argument parsing — subcommands and their + * flags are small and fixed enough that a hand-rolled parser keeps this in + * line with the project's existing minimal dependency footprint (only + * @modelcontextprotocol/sdk, gray-matter, minimatch, zod). + * + * Every subcommand resolves its own locksRoot fresh via resolveLocksRoot() + * (no caching), exactly like every MCP tool handler in server.ts — running + * the CLI from a different worktree of the same repo than a concurrent + * agent session still coordinates correctly, for the same git-common-dir + * reason documented in git.ts. + */ +import { resolveLocksRoot, NotAGitRepoError } from './git.js'; +import { + createLock, + queryLocks, + checkConflicts, + updateLock, + finishLock, + LockNotFoundError, + TaskNotFoundError, + LockNotActiveError, +} from './lock/store.js'; +import type { LockSummary } from './lock/types.js'; + +const USAGE = `agent-locks — filesystem-based work-claiming locks for AI coding agents, shared across every git worktree of the current repository. + +Usage: + agent-locks Start the MCP stdio server (same as running with no args — this is what an MCP client config should use). + agent-locks serve Same as above, explicit. + agent-locks status Human-readable summary of active locks. + agent-locks list [options] List locks. See "agent-locks list --help". + agent-locks check Check whether any active lock overlaps the given glob(s). Informational only — exits 0 either way. + agent-locks claim [options] Create a new lock. See "agent-locks claim --help". + agent-locks update [options] Mark a task done/undone on an existing lock. See "agent-locks update --help". + agent-locks finish [--summary ] Mark a lock done and archive it. + agent-locks --help Show this message. + +Every subcommand talks to the exact same lock store the MCP tools use — a human running "agent-locks status" and an agent calling lock_query see identical, live state.`; + +const LIST_USAGE = `agent-locks list [options] + +Options: + --status Which locks to include. Default: active. + --scope Only locks whose scope overlaps this glob. Repeatable. + --agent Only locks with this exact agent_id. + --text Case-insensitive substring search over title + notes. + --json Print raw JSON instead of a formatted table.`; + +const CLAIM_USAGE = `agent-locks claim [options] + +Options: + --title Required. Short description of the work. + --scope Required. Glob pattern this lock claims. Repeatable. + --task A task to track on this lock. Repeatable; order preserved. + --agent Your own agent id, if you have one. Never fabricated if omitted. + --parent Your parent agent's id, if known. + --json Print raw JSON instead of a short confirmation line.`; + +const UPDATE_USAGE = `agent-locks update [options] + +Options: + --task Required. Must match an existing task's text exactly. + --done Mark the task done (default if neither --done nor --undone given). + --undone Mark the task not done. + --note Append a free-text note to the lock. + --json Print raw JSON instead of a short confirmation line.`; + +class CliUsageError extends Error {} + +interface ParsedFlags { + positionals: string[]; + flags: Map; + boolFlags: Set; +} + +const BOOLEAN_FLAGS = new Set(['--json', '--done', '--undone', '--help']); + +function parseArgs(argv: string[]): ParsedFlags { + const positionals: string[] = []; + const flags = new Map(); + const boolFlags = new Set(); + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + positionals.push(arg); + continue; + } + if (BOOLEAN_FLAGS.has(arg)) { + boolFlags.add(arg); + continue; + } + const value = argv[i + 1]; + if (value === undefined || value.startsWith('--')) { + throw new CliUsageError(`Flag ${arg} requires a value.`); + } + const existing = flags.get(arg) ?? []; + existing.push(value); + flags.set(arg, existing); + i += 1; + } + + return { positionals, flags, boolFlags }; +} + +function oneOf(flags: ParsedFlags['flags'], name: string): string | undefined { + const values = flags.get(name); + if (values === undefined) return undefined; + return values[values.length - 1]; +} + +function allOf(flags: ParsedFlags['flags'], name: string): string[] { + return flags.get(name) ?? []; +} + +function formatLockTable(locks: LockSummary[]): string { + if (locks.length === 0) return '(no locks)'; + const rows = locks.map((lock) => [ + lock.id, + lock.status, + `${lock.percentComplete}%`, + lock.agent_id ?? '(unknown agent)', + lock.scope.join(', '), + lock.title, + ]); + const header = ['ID', 'STATUS', 'DONE', 'AGENT', 'SCOPE', 'TITLE']; + const widths = header.map((h, col) => Math.max(h.length, ...rows.map((r) => r[col].length))); + const formatRow = (row: string[]): string => row.map((cell, col) => cell.padEnd(widths[col])).join(' '); + return [formatRow(header), formatRow(header.map((h) => '-'.repeat(h.length))), ...rows.map(formatRow)].join('\n'); +} + +async function cmdStatus(): Promise { + const locksRoot = await resolveLocksRoot(); + const locks = await queryLocks(locksRoot, {}); + console.log(`agent-locks: ${locks.length} active lock(s) in ${locksRoot}\n`); + console.log(formatLockTable(locks)); +} + +async function cmdList(flags: ParsedFlags): Promise { + if (flags.boolFlags.has('--help')) { + console.log(LIST_USAGE); + return; + } + const status = oneOf(flags.flags, '--status') as 'active' | 'done' | 'all' | undefined; + if (status !== undefined && !['active', 'done', 'all'].includes(status)) { + throw new CliUsageError(`--status must be one of active, done, all (got "${status}").`); + } + const scope = allOf(flags.flags, '--scope'); + const agent_id = oneOf(flags.flags, '--agent'); + const text = oneOf(flags.flags, '--text'); + + const locksRoot = await resolveLocksRoot(); + const locks = await queryLocks(locksRoot, { + status, + scope: scope.length > 0 ? scope : undefined, + agent_id, + text, + }); + + if (flags.boolFlags.has('--json')) { + console.log(JSON.stringify(locks, null, 2)); + } else { + console.log(formatLockTable(locks)); + } +} + +async function cmdCheck(flags: ParsedFlags): Promise { + const scope = flags.positionals; + if (scope.length === 0) { + throw new CliUsageError('agent-locks check requires at least one scope glob, e.g. "agent-locks check src/auth/**".'); + } + const locksRoot = await resolveLocksRoot(); + const conflicts = await checkConflicts(locksRoot, scope); + if (flags.boolFlags.has('--json')) { + console.log(JSON.stringify(conflicts, null, 2)); + return; + } + if (conflicts.length === 0) { + console.log(`No active locks overlap ${scope.join(', ')}.`); + return; + } + console.log(`${conflicts.length} active lock(s) overlap ${scope.join(', ')} — informational only, nothing is blocked:\n`); + console.log(formatLockTable(conflicts)); +} + +async function cmdClaim(flags: ParsedFlags): Promise { + if (flags.boolFlags.has('--help')) { + console.log(CLAIM_USAGE); + return; + } + const title = oneOf(flags.flags, '--title'); + if (!title) throw new CliUsageError('agent-locks claim requires --title. See "agent-locks claim --help".'); + const scope = allOf(flags.flags, '--scope'); + if (scope.length === 0) throw new CliUsageError('agent-locks claim requires at least one --scope. See "agent-locks claim --help".'); + const tasks = allOf(flags.flags, '--task'); + const agent_id = oneOf(flags.flags, '--agent') ?? null; + const parent_agent_id = oneOf(flags.flags, '--parent') ?? null; + + const locksRoot = await resolveLocksRoot(); + const result = await createLock(locksRoot, { title, scope, tasks, agent_id, parent_agent_id }); + + if (flags.boolFlags.has('--json')) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Claimed "${title}" as lock ${result.id}`); + } +} + +async function cmdUpdate(flags: ParsedFlags): Promise { + if (flags.boolFlags.has('--help')) { + console.log(UPDATE_USAGE); + return; + } + const lockId = flags.positionals[0]; + if (!lockId) throw new CliUsageError('agent-locks update requires a lock id as its first argument. See "agent-locks update --help".'); + const taskText = oneOf(flags.flags, '--task'); + if (!taskText) throw new CliUsageError('agent-locks update requires --task. See "agent-locks update --help".'); + if (flags.boolFlags.has('--done') && flags.boolFlags.has('--undone')) { + throw new CliUsageError('Pass at most one of --done / --undone.'); + } + const done = !flags.boolFlags.has('--undone'); + const note = oneOf(flags.flags, '--note'); + + const locksRoot = await resolveLocksRoot(); + const result = await updateLock(locksRoot, { lock_id: lockId, task_text: taskText, done, note }); + + if (flags.boolFlags.has('--json')) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Lock ${result.id}: "${taskText}" marked ${done ? 'done' : 'not done'} (${result.percentComplete}% complete overall).`); + } +} + +async function cmdFinish(flags: ParsedFlags): Promise { + const lockId = flags.positionals[0]; + if (!lockId) throw new CliUsageError('agent-locks finish requires a lock id as its first argument.'); + const summary = oneOf(flags.flags, '--summary'); + + const locksRoot = await resolveLocksRoot(); + const result = await finishLock(locksRoot, { lock_id: lockId, summary }); + + if (flags.boolFlags.has('--json')) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Lock ${result.id} finished and archived.`); + } +} + +/** + * Runs the CLI for the given argv (excluding the node/script prefix, i.e. + * process.argv.slice(2)) and returns the process exit code. Never calls + * process.exit itself so it stays testable in-process. + */ +export async function runCli(argv: string[]): Promise { + const [command, ...rest] = argv; + + if (command === undefined || command === '--help' || command === '-h') { + console.log(USAGE); + return 0; + } + + try { + switch (command) { + case 'status': + await cmdStatus(); + return 0; + case 'list': + await cmdList(parseArgs(rest)); + return 0; + case 'check': + await cmdCheck(parseArgs(rest)); + return 0; + case 'claim': + await cmdClaim(parseArgs(rest)); + return 0; + case 'update': + await cmdUpdate(parseArgs(rest)); + return 0; + case 'finish': + await cmdFinish(parseArgs(rest)); + return 0; + default: + console.error(`agent-locks: unknown command "${command}".\n`); + console.error(USAGE); + return 1; + } + } catch (error) { + if (error instanceof CliUsageError) { + console.error(`agent-locks: ${error.message}`); + return 1; + } + if ( + error instanceof NotAGitRepoError || + error instanceof LockNotFoundError || + error instanceof TaskNotFoundError || + error instanceof LockNotActiveError + ) { + console.error(`agent-locks: ${error.message}`); + return 1; + } + console.error(`agent-locks: unexpected error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + return 1; + } +} diff --git a/src/index.ts b/src/index.ts index 3f9a751..b19299a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,42 @@ #!/usr/bin/env node /** - * Module: agent-locks stdio MCP server entrypoint. + * Module: agent-locks entrypoint — dispatches between the stdio MCP server + * and the human-facing CLI (cli.ts) based on argv. * - * Launched by an MCP client (e.g. Claude Code) as a local subprocess - * communicating over stdin/stdout. See README for how to configure this in - * Claude Code (`claude mcp add` / `.mcp.json`). + * An MCP client (e.g. Claude Code) spawns this exactly as documented in + * README ("args": ["dist/index.js"]) — with NO extra arguments. That is the + * signal used to distinguish the two modes: no arguments (or the explicit + * "serve" alias) starts the MCP server over stdin/stdout; any other first + * argument is treated as a CLI subcommand. This keeps every existing MCP + * client config working unchanged while adding `agent-locks status`, + * `agent-locks claim`, etc. for a human at a terminal or a harness that + * shells out instead of speaking MCP. */ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createServer } from './server.js'; +import { runCli } from './cli.js'; -async function main(): Promise { +async function runServer(): Promise { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); } +async function main(): Promise { + const argv = process.argv.slice(2); + const isServerMode = argv.length === 0 || argv[0] === 'serve'; + if (isServerMode) { + await runServer(); + return; + } + const exitCode = await runCli(argv); + process.exitCode = exitCode; +} + main().catch((error) => { - // stdout is reserved for the MCP JSON-RPC channel; stderr is safe and is - // exactly what Claude Code surfaces for a stdio server's diagnostic output. - process.stderr.write(`agent-locks: fatal error during startup: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + // stdout is reserved for the MCP JSON-RPC channel when in server mode; + // stderr is safe in both modes and is what Claude Code surfaces for a + // stdio server's diagnostic output. + process.stderr.write(`agent-locks: fatal error: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); process.exitCode = 1; });