From b06fbdd14939caeb7456c10cc8fc8d50f75bd043 Mon Sep 17 00:00:00 2001 From: "Gregory R. Warnes" Date: Wed, 29 Jul 2026 20:32:12 -0400 Subject: [PATCH 1/2] chore: gitignore worktrees/ and tmp/ (fork-local dev convention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not upstream-bound — this fork uses a worktree-per-PR workflow that the original repo doesn't need. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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/ From 62ec15df16d3c31c1475ac8e82a4488ab2a54dc5 Mon Sep 17 00:00:00 2001 From: "Gregory R. Warnes" Date: Wed, 29 Jul 2026 21:37:01 -0400 Subject: [PATCH 2/2] feat: add a human-facing CLI alongside the MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `agent-locks status/list/check/claim/update/finish/serve`, thin wrappers around the exact same lock/store.ts functions the 5 MCP tools already call — a human at a terminal, or any agent harness that shells out rather than speaking MCP, gets the same coordination guarantees. index.js now dispatches on argv: no arguments (or the explicit "serve" alias) starts the MCP stdio server exactly as before, so every existing MCP client config keeps working unchanged. Any other first argument runs the CLI and exits with a real code instead of hanging on stdin. No new dependency added — argument parsing is hand-rolled to match the project's existing minimal footprint (only @modelcontextprotocol/sdk, gray-matter, minimatch, zod). 11 new tests in src/__tests__/cli.test.ts: the full claim -> status -> list -> check -> update -> finish lifecycle against a real temp git repo, every documented error path (missing required flags, both --done and --undone passed, bad --status value, update on a nonexistent lock id, update with a non-matching task_text, running outside a git repo), and one test that spawns the actual compiled dist/index.js as a real subprocess with a CLI subcommand to prove the argv-dispatch branch works end-to-end (complementing e2e.test.ts, which already proves the zero-argument branch starts the MCP server). typecheck clean. README gains a "CLI usage" section documenting all 7 subcommands. Pre-existing note: `pnpm test` on macOS will show 1 unrelated failure in git.test.ts (a symlink-canonicalization mismatch in resolveLocksRoot, not touched by this change) — already has an open fix: PR #1. Co-Authored-By: Claude Sonnet 5 --- README.md | 33 ++++ dist/index.js | 256 +++++++++++++++++++++++++++++- src/__tests__/cli.test.ts | 194 +++++++++++++++++++++++ src/cli.ts | 318 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 35 ++++- 5 files changed, 826 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/cli.test.ts create mode 100644 src/cli.ts 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; });