diff --git a/CLAUDE.md b/CLAUDE.md index 8a8b6fa..4400737 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,17 +36,20 @@ Requires Node.js 22+ and pnpm. ```bash pnpm install pnpm build # pnpm -r build (build before running the CLI/daemon) -pnpm daemon # HTTP API on http://127.0.0.1:9473 +pnpm daemon # explicit daemon start; init/work auto-start it pnpm seed # optional demo projects (Beacon, Silo, Forge) pnpm dashboard # dev UI on http://127.0.0.1:5173 pnpm dashboard:preview # production build + preview -pnpm coord # run the built CLI (node packages/cli/dist/index.js) +pnpm coord init # profile + folder-named project +pnpm coord work # create/join worktree and launch the agent +pnpm coord # run the built CLI (node packages/cli/dist/index.js) pnpm test # per-package unit tests (node --test) pnpm test:integration # builds, then runs tests/integration/*.test.mjs ``` The CLI runs against the built output, so `pnpm build` after changing CLI or -daemon code before exercising a flow. +daemon code before exercising a flow. Run `npm link` from this repository to +make the development build available as `coord` in other repositories. ## Conventions diff --git a/PROGRESS.md b/PROGRESS.md index f516e33..deaf0da 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -5,7 +5,7 @@ digest; the authoritative checklist stays in [`todo.md`](./todo.md), and the per-phase build plan lives in [`report/team-implementation-plan.md`](./report/team-implementation-plan.md). -_Last updated: 2026-07-12._ +_Last updated: 2026-07-25._ ## At a glance @@ -22,7 +22,8 @@ Runnable end-to-end on one machine: ```bash pnpm install pnpm build -pnpm daemon # HTTP API on http://127.0.0.1:9473 +pnpm coord init # auto-start daemon, profile, folder-named project +pnpm coord work my-track # create/join worktree and launch the coding agent pnpm seed # optional demo projects: Beacon, Silo, Forge pnpm dashboard # React UI on http://127.0.0.1:5173 pnpm test:integration # full CLI + daemon flow, incl. vault-flow.test.mjs @@ -32,12 +33,14 @@ pnpm test:integration # full CLI + daemon flow, incl. vault-flow.test.mjs SQLite state, projects/tracks, participants, worktrees, publish events, vault materialization + rebuild, `vault context`, FTS5 vault search, avatars, repo context, and Supabase relay mirroring/sync. -- **CLI** (`packages/cli`, `pnpm coord`) — `init` (with `--relay - local|supabase`), `status`, `project create|list`, `start`, `join`, `enter`, +- **CLI** (`packages/cli`, `pnpm coord`) — simplified onboarding through + `init` (auto-start daemon + folder-named project) and `work` (create/join a + worktree + launch Claude Code, Codex, Cursor, Ghost, or a shell). Bare + `coord` selects a track and launches the default agent. Lower-level commands + remain available: `status`, `project create|list`, `start`, `join`, `enter`, `publish`, `vault read|context|search`, `context` (compact vault context + - teammate deltas), `hook install|uninstall|status` (Claude Code - auto-injection), `ws show|who|branches`, plus relay commands `login`, - `sessions`, `list`, `sync`, `status relay`. + teammate deltas), `hook install|uninstall|status`, `ws show|who|branches`, + plus relay commands `login`, `sessions`, `list`, `sync`, `status relay`. - **Vault** (`packages/vault`) — flat Phase 1 files (`decisions.md`, `observations.md`, `blockers.md`, `test-results.md`, `attempts.md`) materialized from `events.jsonl`; row annotations (`[tb color= assign=]`) @@ -55,8 +58,10 @@ pnpm test:integration # full CLI + daemon flow, incl. vault-flow.test.mjs `participants`, `vault/context`, `inbox`, `conflicts`) and six tools (`team_publish`, `vault_search`, `vault_read`, `workspace_status`, `team_ask`, `team_reply`) — all calling the daemon. Workspace resolution - from explicit params, local `state.sqlite` worktree mapping, or - `.coord/.active` fallback. Start with `coord mcp`. Integration tests + from explicit params, launcher environment, local `state.sqlite` worktree + mapping, or `.coord/.active` fallback. `coord work` configures the MCP server + for Claude Code and Codex automatically; it can also be started directly + with `coord mcp`. Integration tests spawn the server over stdio and verify JSON-RPC handshake, resource/tool lists, live resource reads, and full ask/reply/conflict flows. diff --git a/README.md b/README.md index deaf8b6..d69dd18 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,36 @@ Requires Node.js 22+ and pnpm. ```bash pnpm install pnpm build -pnpm daemon # API on http://127.0.0.1:9473 +``` + +To use the development build as `coord` from any repository: + +```bash +npm link +cd /path/to/your/project +coord init +coord work my-track +``` + +`coord init` starts the local daemon when needed, creates your profile, and +creates a project named after the repository folder. `coord work` creates or +joins the track worktree and launches your configured agent there. Passing +`--claude`, `--codex`, `--cursor`, or `--shell` overrides the agent for one +run. After setup, bare `coord` selects a track and launches the default agent. + +For dashboard development: + +```bash pnpm seed # Optional demo projects: Beacon, Silo, Forge pnpm dashboard # UI on http://127.0.0.1:5173 ``` -**Your own data (no seed):** +When running without the global development link, prefix commands with +`pnpm`, for example: ```bash pnpm coord init -pnpm coord project create --name "My App" -pnpm coord start my-track +pnpm coord work my-track ``` Optional: add `PEXELS_API_KEY` to `.env` for flower profile photos. diff --git a/docs/cli-worktrees.md b/docs/cli-worktrees.md index 650f6d7..dd53ba5 100644 --- a/docs/cli-worktrees.md +++ b/docs/cli-worktrees.md @@ -1,4 +1,4 @@ -# CLI Worktrees (`start` / `join`) +# CLI Worktrees (`work` / `start` / `join`) How the `@coord/cli` creates and manages git worktrees. Owner: Kushagra. @@ -13,6 +13,29 @@ How the `@coord/cli` creates and manages git worktrees. Owner: Kushagra. - **Branch:** `coord//` — byte-for-byte the daemon's `branchForParticipant` (index.ts:778). The session name is used **raw** (validated `^[A-Za-z0-9._-]+$`); the path segment is sanitized. `` mirrors the daemon's `safeDisplayName`. +## Recommended flow: `coord work [TRACK]` + +`coord work` is the user-facing entry point over the lower-level `start`, +`join`, and `enter` operations: + +1. Start the local daemon if it is not already healthy. +2. Run interactive initialization when the repository has no profile. +3. Resolve the requested track from the argument, current Coord branch, only + available track, or an interactive picker. +4. Create a new track or join an existing local/relay track, repairing a + missing local worktree pointer when needed. +5. Mark the track active for MCP resolution. +6. Launch the configured agent inside the participant worktree. + +Claude Code is launched with a generated project MCP config and its Coord +context hook installed. Codex is launched with temporary `-c +mcp_servers.coord.*` overrides, so the user's global Codex config is not +modified. Use `--no-launch` to prepare the worktree only, or `--claude`, +`--codex`, `--cursor`, `--ghost`, and `--shell` for a one-run override. +These launch arguments follow the official +[Claude Code CLI reference](https://docs.anthropic.com/en/docs/claude-code/cli-usage) +and [Codex CLI reference](https://developers.openai.com/codex/cli/reference/). + ## `coord join [NAME] [--as DISPLAY_NAME]` Flow (git-first, then daemon, so the daemon never records a row for a worktree that failed to materialize): @@ -24,7 +47,7 @@ Flow (git-first, then daemon, so the daemon never records a row for a worktree t 5. `POST /workspaces/join` with `{ repoRoot, sessionName, displayName, agent, worktreePath }`. 6. Write a local pointer `.coord/workspaces//.worktree..json` (so a future `enter` resolves the path without reading SQLite). -**Enter the worktree:** `cd "$(... printed path)" && claude` (printed by the command). +**Continue in the worktree:** `coord work ` (printed by the command). ### Edge cases handled @@ -47,9 +70,13 @@ packages/cli/src/lib/worktree.ts # prepareParticipantWorktree / rollbackPartic packages/cli/src/lib/pointers.ts # .worktree..json read/write packages/cli/src/commands/start.ts # runStart packages/cli/src/commands/track.ts # shared join/registration helpers +packages/cli/src/commands/work.ts # high-level worktree + agent launcher +packages/cli/src/lib/agent.ts # agent aliases, detection, commands ``` -Tests: `packages/cli/test/worktree.test.cjs` (naming parity + worktree orchestration with a fake `GitRunner`). +Tests: `packages/cli/test/worktree.test.cjs` (naming parity + worktree +orchestration with a fake `GitRunner`) and `packages/cli/test/work.test.cjs` +(Claude Code, Codex, Cursor, and shell launch plans). ## Known limitations (pending daemon asks — see `nihal-daemon-requests.md`) diff --git a/package.json b/package.json index b574be3..3fd3143 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "description": "Coord — shared workspace for teams building with AI agents.", + "bin": { + "coord": "packages/cli/dist/index.js" + }, "scripts": { "build": "pnpm -r build", "dashboard": "pnpm --filter @coord/dashboard dev --host 127.0.0.1", diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index f997939..5d91441 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -3,6 +3,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, openSync, c import { join, resolve } from 'node:path'; import type { ClientOptions } from '../daemon-client'; +const DEFAULT_DAEMON_PORT = 9473; +const DAEMON_START_TIMEOUT_MS = 5_000; + type DaemonState = { pid: number; port: number; @@ -45,24 +48,75 @@ function daemonEntryPoint(): string { function parsePort(argv: string[], fallback: string | undefined): number { const index = argv.indexOf('--port'); const value = index >= 0 ? argv[index + 1] : fallback; - const port = Number(value ?? 9473); + const port = Number(value ?? DEFAULT_DAEMON_PORT); if (!Number.isInteger(port) || port <= 0) { throw new Error('Daemon port must be a positive integer.'); } return port; } -async function runDaemonStart(argv: string[], options: ClientOptions): Promise { +function portFromBaseUrl(baseUrl: string | undefined): number { + if (!baseUrl) return parsePort([], process.env.COORD_DAEMON_PORT); + + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error(`Invalid Coord daemon URL: ${baseUrl}`); + } + + if (url.protocol !== 'http:' || (url.hostname !== '127.0.0.1' && url.hostname !== 'localhost')) { + throw new Error( + `Cannot auto-start a remote daemon at ${baseUrl}. Start it separately or unset COORD_DAEMON_URL.` + ); + } + + return Number(url.port || 80); +} + +async function daemonIsHealthy(baseUrl: string): Promise { + try { + const response = await fetch(new URL('/health', baseUrl), { + signal: AbortSignal.timeout(750) + }); + if (!response.ok) return false; + const body = await response.json() as { ok?: boolean }; + return body.ok === true; + } catch { + return false; + } +} + +async function waitForDaemon(baseUrl: string): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < DAEMON_START_TIMEOUT_MS) { + if (await daemonIsHealthy(baseUrl)) return; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 75)); + } + throw new Error(`Coord daemon did not become ready at ${baseUrl}. Check .coord/daemon/daemon.log.`); +} + +async function startManagedDaemon( + options: ClientOptions, + port: number, + quiet: boolean +): Promise<{ started: boolean; baseUrl: string }> { + const baseUrl = `http://127.0.0.1:${port}`; const existing = readState(options.repoRoot); if (existing && isProcessAlive(existing.pid)) { - console.log(`Coord daemon already running on ${existing.baseUrl}`); - console.log(`PID: ${existing.pid}`); - console.log(`Log: ${existing.logPath}`); - return; + if (await daemonIsHealthy(baseUrl)) { + if (!quiet) { + console.log(`Coord daemon already running on ${baseUrl}`); + console.log(`PID: ${existing.pid}`); + console.log(`Log: ${existing.logPath}`); + } + return { started: false, baseUrl }; + } + throw new Error( + `A managed Coord daemon is running as PID ${existing.pid}, but ${baseUrl} is not healthy. Check ${existing.logPath}.` + ); } - const port = parsePort(argv, process.env.COORD_DAEMON_PORT); - const baseUrl = `http://127.0.0.1:${port}`; const dir = stateDir(options.repoRoot); mkdirSync(dir, { recursive: true }); @@ -95,9 +149,42 @@ async function runDaemonStart(argv: string[], options: ClientOptions): Promise { + const configuredBaseUrl = options.baseUrl ?? `http://127.0.0.1:${parsePort([], process.env.COORD_DAEMON_PORT)}`; + if (await daemonIsHealthy(configuredBaseUrl)) return false; + + const port = portFromBaseUrl(configuredBaseUrl); + const result = await startManagedDaemon(options, port, true); + return result.started; +} + +async function runDaemonStart(argv: string[], options: ClientOptions): Promise { + const port = argv.includes('--port') + ? parsePort(argv, process.env.COORD_DAEMON_PORT) + : portFromBaseUrl(options.baseUrl); + await startManagedDaemon(options, port, false); } async function runDaemonStatus(options: ClientOptions): Promise { diff --git a/packages/cli/src/commands/hook.ts b/packages/cli/src/commands/hook.ts index 66bd0a8..fcfa356 100644 --- a/packages/cli/src/commands/hook.ts +++ b/packages/cli/src/commands/hook.ts @@ -47,7 +47,12 @@ function isInstalled(settings: ClaudeSettings): boolean { return (settings.hooks?.SessionStart ?? []).some(entryTargetsCoord); } -async function runHookInstall(argv: string[], options: ClientOptions, cwd: string): Promise { +export async function installClaudeHook( + argv: string[], + options: ClientOptions, + cwd: string, + quiet = false +): Promise { const command = parseFlag(argv, '--command') ?? HOOK_COMMAND; const path = settingsPath(cwd); const settings = readSettings(path); @@ -68,18 +73,24 @@ async function runHookInstall(argv: string[], options: ClientOptions, cwd: strin } } writeSettings(path, settings); - console.log(`Coord SessionStart hook already present — refreshed in ${path}`); + if (!quiet) { + console.log(`Coord SessionStart hook already present — refreshed in ${path}`); + } } else { sessionStart.push({ hooks: [{ type: 'command', command }] }); writeSettings(path, settings); - console.log(`Installed Coord SessionStart hook in ${path}`); - console.log(`Command: ${command}`); + if (!quiet) { + console.log(`Installed Coord SessionStart hook in ${path}`); + console.log(`Command: ${command}`); + } } - if (!autoInject) { + if (!autoInject && !quiet) { console.log('Note: config.autoInject is false — the hook is installed but you disabled auto-injection in .coord/config.json.'); } - console.log('New Claude Code sessions in this worktree will now receive shared context automatically.'); + if (!quiet) { + console.log('New Claude Code sessions in this worktree will now receive shared context automatically.'); + } } function runHookUninstall(cwd: string): void { @@ -133,7 +144,7 @@ export async function runHook(argv: string[], options: ClientOptions): Promise { +async function resolveRelayMode( + argv: string[], + promptWhenMissing: boolean +): Promise { const flag = parseFlag(argv, '--relay') ?? parseFlag(argv, '--relay-mode'); if (flag) { return normalizeRelayMode(flag); } // Only prompt interactively; in CI / hook / piped contexts leave it unset so // the daemon keeps whatever the repo config already has (default `local`). - if (process.stdin.isTTY) { + if (promptWhenMissing && process.stdin.isTTY) { const answer = await ask('Relay mode (local/supabase)', 'local'); return normalizeRelayMode(answer); } @@ -33,7 +46,19 @@ async function resolveRelayMode(argv: string[]): Promise } export async function runInit(argv: string[], options: ClientOptions): Promise { - const relayMode = await resolveRelayMode(argv); + const daemonStarted = await ensureDaemonRunning(options); + if (daemonStarted) { + console.log('Started the Coord daemon.'); + } + + const existing = await getUserProfile(options); + if (!existing.ok) { + throw new Error(existing.error.message); + } + + // First-time setup offers the relay choice. Re-running init is a repair + // operation and preserves the existing mode unless --relay is explicit. + const relayMode = await resolveRelayMode(argv, !existing.data.profile); const config = await initConfig(options, { relayMode }); if (!config.ok) { @@ -44,16 +69,12 @@ export async function runInit(argv: string[], options: ClientOptions): Promise'); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index da6b1b8..d7efebd 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -69,5 +69,5 @@ export async function runStart(argv: string[], options: ClientOptions): Promise< console.log(`Workspace id: ${manifest.id}`); console.log(`Branch: ${worktree.branch}`); console.log(`Worktree: ${worktree.path}`); - console.log(`Enter it with: cd "${worktree.path}" && claude`); + console.log(`Continue with: coord work ${manifest.sessionName}`); } diff --git a/packages/cli/src/commands/track.ts b/packages/cli/src/commands/track.ts index e5c40ac..63551b7 100644 --- a/packages/cli/src/commands/track.ts +++ b/packages/cli/src/commands/track.ts @@ -150,9 +150,18 @@ export async function runTrackJoin(argv: string[], options: ClientOptions): Prom // Duplicate display name on this track: the worktree may be legitimate prior // work, so surface it and do NOT roll back (daemon ask #5 is only partial). if (/unique constraint failed:\s*participants/i.test(joined.error.message)) { + writeWorktreePointer(options.repoRoot, { + workspaceId: track.id, + sessionName: track.sessionName, + displayName, + path: worktree.path, + branch: worktree.branch, + baseCommit: track.baseCommit, + role: 'joiner' + }); console.log(`You are already a participant in session "${track.sessionName}" as ${displayName}.`); console.log(`Worktree: ${worktree.path}`); - console.log(`Enter it with: cd "${worktree.path}" && claude`); + console.log(`Continue with: coord work ${track.sessionName}`); return; } // Otherwise roll back only what we created this run. @@ -176,5 +185,5 @@ export async function runTrackJoin(argv: string[], options: ClientOptions): Prom console.log(`${verb} session "${track.sessionName}" as ${displayName}.`); console.log(`Branch: ${worktree.branch}`); console.log(`Worktree: ${worktree.path}`); - console.log(`Enter it with: cd "${worktree.path}" && claude`); + console.log(`Continue with: coord work ${track.sessionName}`); } diff --git a/packages/cli/src/commands/work.ts b/packages/cli/src/commands/work.ts new file mode 100644 index 0000000..f182c51 --- /dev/null +++ b/packages/cli/src/commands/work.ts @@ -0,0 +1,337 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { LocalUserProfile, Workspace } from '@coord/core'; +import type { ClientOptions } from '../daemon-client'; +import { getUserProfile, listRelaySessions, listTracks } from '../daemon-client'; +import { ask, hasFlag, parseFlag } from '../prompt'; +import { + agentCommand, + commandExists, + detectDefaultAgent, + displayAgent, + normalizeAgent, + type LaunchAgent +} from '../lib/agent'; +import { currentSessionNameFromBranch } from '../lib/current-track'; +import { readWorktreePointer, writeActiveTrack, type WorktreePointer } from '../lib/pointers'; +import { ensureDaemonRunning } from './daemon'; +import { installClaudeHook } from './hook'; +import { runInit } from './init'; +import { runStart } from './start'; +import { runTrackJoin } from './track'; + +export type AgentLaunchPlan = { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +}; + +function cliEntryPoint(): string { + return resolve(__dirname, '../index.js'); +} + +function quoteToml(value: string): string { + return JSON.stringify(value); +} + +function coordEnvironment( + options: ClientOptions, + track: Workspace +): NodeJS.ProcessEnv { + const daemonUrl = options.baseUrl ?? 'http://127.0.0.1:9473'; + return { + ...process.env, + COORD_REPO_ROOT: options.repoRoot, + COORD_DAEMON_URL: daemonUrl, + COORD_WORKSPACE_ID: track.id, + COORD_SESSION_NAME: track.sessionName + }; +} + +function writeClaudeMcpConfig( + options: ClientOptions, + track: Workspace +): string { + const daemonUrl = options.baseUrl ?? 'http://127.0.0.1:9473'; + const dir = join(options.repoRoot, '.coord', 'workspaces', track.sessionName); + mkdirSync(dir, { recursive: true }); + const path = join(dir, 'claude-mcp.json'); + writeFileSync(path, `${JSON.stringify({ + mcpServers: { + coord: { + command: process.execPath, + args: [cliEntryPoint(), 'mcp'], + env: { + COORD_REPO_ROOT: options.repoRoot, + COORD_DAEMON_URL: daemonUrl, + COORD_WORKSPACE_ID: track.id, + COORD_SESSION_NAME: track.sessionName + } + } + } + }, null, 2)}\n`); + return path; +} + +export function buildAgentLaunchPlan( + agent: LaunchAgent, + pointer: WorktreePointer, + track: Workspace, + options: ClientOptions, + claudeMcpConfigPath?: string +): AgentLaunchPlan { + const command = agentCommand(agent); + const env = coordEnvironment(options, track); + + if (agent === 'claude-code') { + if (!claudeMcpConfigPath) { + throw new Error('Claude MCP configuration path is required.'); + } + return { + command, + args: ['--mcp-config', claudeMcpConfigPath], + cwd: pointer.path, + env + }; + } + + if (agent === 'codex') { + const daemonUrl = options.baseUrl ?? 'http://127.0.0.1:9473'; + const mcpArgs = JSON.stringify([cliEntryPoint(), 'mcp']); + const mcpEnv = [ + `COORD_REPO_ROOT = ${quoteToml(options.repoRoot)}`, + `COORD_DAEMON_URL = ${quoteToml(daemonUrl)}`, + `COORD_WORKSPACE_ID = ${quoteToml(track.id)}`, + `COORD_SESSION_NAME = ${quoteToml(track.sessionName)}` + ].join(', '); + return { + command, + args: [ + '-C', + pointer.path, + '-c', + `mcp_servers.coord.command=${quoteToml(process.execPath)}`, + '-c', + `mcp_servers.coord.args=${mcpArgs}`, + '-c', + `mcp_servers.coord.env={ ${mcpEnv} }` + ], + cwd: pointer.path, + env + }; + } + + if (agent === 'cursor') { + return { command, args: ['.'], cwd: pointer.path, env }; + } + + if (agent === 'shell') { + return { + command, + args: process.platform === 'win32' ? [] : ['-l'], + cwd: pointer.path, + env + }; + } + + return { command, args: [], cwd: pointer.path, env }; +} + +function positionalArgs(argv: string[]): string[] { + const valueFlags = new Set(['--agent', '--project', '--base', '--base-ref']); + const values: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (valueFlags.has(arg)) { + index += 1; + continue; + } + if (!arg.startsWith('-')) values.push(arg); + } + return values; +} + +async function chooseTrack(argv: string[], tracks: Workspace[]): Promise { + const explicit = positionalArgs(argv)[0]; + if (explicit) return explicit; + + const current = currentSessionNameFromBranch(); + if (current) return current; + if (tracks.length === 1) return tracks[0].sessionName; + + if (!process.stdin.isTTY) { + if (tracks.length === 0) { + throw new Error('No tracks exist yet. Run `coord work `.'); + } + throw new Error('More than one track exists. Run `coord work `.'); + } + + if (tracks.length > 0) { + console.log('Tracks:'); + for (const track of tracks) { + console.log(` - ${track.sessionName}`); + } + } + return ask('Track name', tracks[0]?.sessionName ?? 'main'); +} + +function requestedAgent(argv: string[], profile: LocalUserProfile): LaunchAgent | undefined { + if (hasFlag(argv, '--claude')) return 'claude-code'; + if (hasFlag(argv, '--codex')) return 'codex'; + if (hasFlag(argv, '--cursor')) return 'cursor'; + if (hasFlag(argv, '--ghost')) return 'ghost'; + if (hasFlag(argv, '--shell')) return 'shell'; + + const flag = parseFlag(argv, '--agent'); + if (flag) return normalizeAgent(flag); + if (profile.defaultAgent && profile.defaultAgent !== 'unknown') { + return profile.defaultAgent; + } + return detectDefaultAgent(); +} + +async function listAvailableTracks(options: ClientOptions): Promise { + const local = await listTracks(options); + if (!local.ok) throw new Error(local.error.message); + + // A signed-in teammate may only know about a session through the relay. + // Include those sessions in the same picker, while keeping local state + // authoritative if the same track is present in both places. + const bySessionName = new Map( + local.data.tracks.map((track) => [track.sessionName, track]) + ); + const remote = await listRelaySessions(options); + if (remote.ok) { + for (const track of remote.data.sessions) { + if (!bySessionName.has(track.sessionName)) { + bySessionName.set(track.sessionName, track); + } + } + } + return [...bySessionName.values()]; +} + +async function resolveProfile(options: ClientOptions): Promise { + let result = await getUserProfile(options); + if (!result.ok) throw new Error(result.error.message); + + if (!result.data.profile) { + if (!process.stdin.isTTY) { + throw new Error('Coord is not initialized. Run `coord init` first.'); + } + console.log('This repository is not initialized yet. Let’s set it up.'); + await runInit([], options); + result = await getUserProfile(options); + if (!result.ok) throw new Error(result.error.message); + } + + if (!result.data.profile) { + throw new Error('Coord could not load the local profile after initialization.'); + } + return result.data.profile; +} + +async function ensureParticipantWorktree( + argv: string[], + options: ClientOptions, + profile: LocalUserProfile, + sessionName: string, + tracks: Workspace[] +): Promise<{ pointer: WorktreePointer; track: Workspace }> { + let track = tracks.find((candidate) => candidate.sessionName === sessionName); + let pointer = readWorktreePointer(options.repoRoot, sessionName, profile.displayName); + + if (!track) { + const startArgs = [sessionName]; + const project = parseFlag(argv, '--project'); + const base = parseFlag(argv, '--base-ref') ?? parseFlag(argv, '--base'); + if (base) startArgs.push(base); + if (project) startArgs.push('--project', project); + await runStart(startArgs, options); + + const refreshed = await listTracks(options); + if (!refreshed.ok) throw new Error(refreshed.error.message); + track = refreshed.data.tracks.find((candidate) => candidate.sessionName === sessionName); + pointer = readWorktreePointer(options.repoRoot, sessionName, profile.displayName); + } else if (!pointer || !existsSync(pointer.path)) { + await runTrackJoin([sessionName], options); + pointer = readWorktreePointer(options.repoRoot, sessionName, profile.displayName); + } + + if (!track) { + throw new Error(`Track "${sessionName}" was not available after setup.`); + } + if (!pointer || !existsSync(pointer.path)) { + throw new Error(`Coord could not resolve your worktree for "${sessionName}".`); + } + return { pointer, track }; +} + +async function launchAgent(plan: AgentLaunchPlan): Promise { + await new Promise((resolvePromise, reject) => { + const child = spawn(plan.command, plan.args, { + cwd: plan.cwd, + env: plan.env, + stdio: 'inherit' + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (signal) { + process.exitCode = 1; + } else if (code && code !== 0) { + process.exitCode = code; + } + resolvePromise(); + }); + }); +} + +export async function runWork(argv: string[], options: ClientOptions): Promise { + const daemonStarted = await ensureDaemonRunning(options); + if (daemonStarted) console.log('Started the Coord daemon.'); + + const profile = await resolveProfile(options); + const tracks = await listAvailableTracks(options); + + const sessionName = (await chooseTrack(argv, tracks)).trim(); + if (!sessionName) throw new Error('Track name is required.'); + + const { pointer, track } = await ensureParticipantWorktree( + argv, + options, + profile, + sessionName, + tracks + ); + writeActiveTrack(options.repoRoot, track.sessionName); + + console.log(`Ready on "${track.sessionName}" at ${pointer.path}`); + if (hasFlag(argv, '--no-launch')) return; + + let agent = requestedAgent(argv, profile); + if (!agent && process.stdin.isTTY) { + agent = normalizeAgent(await ask('Agent (claude/codex/cursor/ghost/shell)', 'claude')); + } + if (!agent) { + throw new Error('No coding agent was found. Pass `--agent claude`, `--agent codex`, or `--shell`.'); + } + + const command = agentCommand(agent); + if (!commandExists(command)) { + throw new Error( + `${displayAgent(agent)} is not installed or "${command}" is not on PATH. Choose another agent with --agent or use --shell.` + ); + } + + let claudeMcpConfigPath: string | undefined; + if (agent === 'claude-code') { + await installClaudeHook([], options, pointer.path, true); + claudeMcpConfigPath = writeClaudeMcpConfig(options, track); + } + + const plan = buildAgentLaunchPlan(agent, pointer, track, options, claudeMcpConfigPath); + console.log(`Launching ${displayAgent(agent)}…`); + await launchAgent(plan); +} diff --git a/packages/cli/src/daemon-client.ts b/packages/cli/src/daemon-client.ts index 082a75a..5c9b4f0 100644 --- a/packages/cli/src/daemon-client.ts +++ b/packages/cli/src/daemon-client.ts @@ -40,7 +40,10 @@ async function request(url: string, init: RequestInit = {}): Promise { const argv = process.argv.slice(2); - const command = argv[0]; - - if (!command || command === '--help' || command === '-h') { + const command = argv[0] ?? 'work'; + + if ( + command === '--help' + || command === '-h' + || command === 'help' + || argv.includes('--help') + || argv.includes('-h') + ) { usage(); - process.exit(command ? 0 : 1); + return; } const repoRoot = resolveRepoRoot(); @@ -72,6 +81,11 @@ async function main(): Promise { return; } + if (command === 'work') { + await runWork(argv.slice(1), options); + return; + } + if (command === 'project') { const sub = argv[1]; if (sub === 'create') { diff --git a/packages/cli/src/lib/agent.ts b/packages/cli/src/lib/agent.ts new file mode 100644 index 0000000..37367fd --- /dev/null +++ b/packages/cli/src/lib/agent.ts @@ -0,0 +1,70 @@ +import { execFileSync } from 'node:child_process'; +import { accessSync, constants } from 'node:fs'; +import type { AgentKind } from '@coord/core'; + +export type LaunchAgent = Exclude | 'shell'; + +const AGENT_ALIASES: Record = { + claude: 'claude-code', + 'claude-code': 'claude-code', + codex: 'codex', + cursor: 'cursor', + ghost: 'ghost', + shell: 'shell' +}; + +const AGENT_COMMANDS: Record = { + 'claude-code': 'claude', + codex: 'codex', + cursor: 'cursor', + ghost: 'ghost', + shell: process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/sh') +}; + +export function normalizeAgent(value: string): LaunchAgent { + const agent = AGENT_ALIASES[value.trim().toLowerCase()]; + if (!agent) { + throw new Error(`Unknown agent "${value}". Use claude, codex, cursor, ghost, or shell.`); + } + return agent; +} + +export function agentCommand(agent: LaunchAgent): string { + return AGENT_COMMANDS[agent]; +} + +export function commandExists(command: string): boolean { + if (command.includes('/') || command.includes('\\')) { + try { + accessSync(command, process.platform === 'win32' ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } + } + + try { + execFileSync(process.platform === 'win32' ? 'where' : 'which', [command], { + stdio: 'ignore', + timeout: 2_000 + }); + return true; + } catch { + return false; + } +} + +export function detectDefaultAgent(): LaunchAgent | undefined { + for (const agent of ['claude-code', 'codex', 'cursor', 'ghost'] as const) { + if (commandExists(agentCommand(agent))) return agent; + } + return undefined; +} + +export function displayAgent(agent: LaunchAgent): string { + if (agent === 'claude-code') return 'Claude Code'; + if (agent === 'codex') return 'Codex'; + if (agent === 'cursor') return 'Cursor'; + if (agent === 'ghost') return 'Ghost'; + return 'shell'; +} diff --git a/packages/cli/src/lib/pointers.ts b/packages/cli/src/lib/pointers.ts index 2c39c63..a630460 100644 --- a/packages/cli/src/lib/pointers.ts +++ b/packages/cli/src/lib/pointers.ts @@ -49,3 +49,11 @@ export function readWorktreePointer( return null; } } + +export function writeActiveTrack(repoRoot: string, sessionName: string): string { + const coordDir = join(repoRoot, '.coord'); + mkdirSync(coordDir, { recursive: true }); + const path = join(coordDir, '.active'); + writeFileSync(path, `${sessionName.trim()}\n`); + return path; +} diff --git a/packages/cli/test/work.test.cjs b/packages/cli/test/work.test.cjs new file mode 100644 index 0000000..1b758b0 --- /dev/null +++ b/packages/cli/test/work.test.cjs @@ -0,0 +1,79 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildAgentLaunchPlan +} = require('../dist/commands/work.js'); +const { + normalizeAgent +} = require('../dist/lib/agent.js'); + +const pointer = { + workspaceId: 'ws_123', + sessionName: 'auth-redesign', + displayName: 'Ada Lovelace', + path: '/tmp/coord-worktree', + branch: 'coord/auth-redesign/ada-lovelace', + baseCommit: 'abc123', + role: 'creator' +}; + +const track = { + id: 'ws_123', + sessionName: 'auth-redesign' +}; + +const options = { + repoRoot: '/tmp/coord-repo', + baseUrl: 'http://127.0.0.1:9473' +}; + +test('agent aliases normalize to stable profile values', () => { + assert.equal(normalizeAgent('claude'), 'claude-code'); + assert.equal(normalizeAgent('CLAUDE-CODE'), 'claude-code'); + assert.equal(normalizeAgent('codex'), 'codex'); + assert.equal(normalizeAgent('shell'), 'shell'); + assert.throws(() => normalizeAgent('made-up-agent'), /Unknown agent/); +}); + +test('Claude launch uses the worktree and an explicit per-launch MCP config', () => { + const plan = buildAgentLaunchPlan( + 'claude-code', + pointer, + track, + options, + '/tmp/coord-repo/.coord/workspaces/auth-redesign/claude-mcp.json' + ); + + assert.equal(plan.command, 'claude'); + assert.equal(plan.cwd, pointer.path); + assert.deepEqual(plan.args, [ + '--mcp-config', + '/tmp/coord-repo/.coord/workspaces/auth-redesign/claude-mcp.json' + ]); + assert.equal(plan.env.COORD_WORKSPACE_ID, track.id); + assert.equal(plan.env.COORD_SESSION_NAME, track.sessionName); +}); + +test('Codex launch sets its working root and injects Coord MCP without global config writes', () => { + const plan = buildAgentLaunchPlan('codex', pointer, track, options); + + assert.equal(plan.command, 'codex'); + assert.equal(plan.cwd, pointer.path); + assert.deepEqual(plan.args.slice(0, 2), ['-C', pointer.path]); + assert.ok(plan.args.some((arg) => arg.includes('mcp_servers.coord.command'))); + assert.ok(plan.args.some((arg) => arg.includes('mcp_servers.coord.args'))); + assert.ok(plan.args.some((arg) => arg.includes('COORD_WORKSPACE_ID'))); + assert.equal(plan.env.COORD_REPO_ROOT, options.repoRoot); +}); + +test('Cursor opens the worktree and shell starts as a login shell', () => { + const cursor = buildAgentLaunchPlan('cursor', pointer, track, options); + assert.equal(cursor.command, 'cursor'); + assert.deepEqual(cursor.args, ['.']); + + const shell = buildAgentLaunchPlan('shell', pointer, track, options); + assert.equal(shell.cwd, pointer.path); + assert.equal(shell.command, process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/sh')); + assert.deepEqual(shell.args, process.platform === 'win32' ? [] : ['-l']); +}); diff --git a/packages/mcp/src/resolution.ts b/packages/mcp/src/resolution.ts index cf716a8..02c3c31 100644 --- a/packages/mcp/src/resolution.ts +++ b/packages/mcp/src/resolution.ts @@ -12,12 +12,14 @@ export type ResolutionInput = { export async function resolveWorkspaceContext(input: ResolutionInput): Promise { const baseUrl = input.baseUrl ?? process.env.COORD_DAEMON_URL; + const workspaceId = input.workspaceId ?? process.env.COORD_WORKSPACE_ID; + const sessionName = input.sessionName ?? process.env.COORD_SESSION_NAME; - // 1. Explicit params - if (input.workspaceId || input.sessionName) { + // 1. Explicit params or launcher-provided environment + if (workspaceId || sessionName) { return { - workspaceId: input.workspaceId, - sessionName: input.sessionName, + workspaceId, + sessionName, repoRoot: input.repoRoot, baseUrl }; diff --git a/packages/mcp/test/resolution.test.cjs b/packages/mcp/test/resolution.test.cjs index c690e0d..7ea4ad3 100644 --- a/packages/mcp/test/resolution.test.cjs +++ b/packages/mcp/test/resolution.test.cjs @@ -33,6 +33,23 @@ test('explicit sessionName returns with that session name', async () => { assert.equal(result.workspaceId, undefined); }); +test('launcher-provided environment resolves the active workspace', async () => { + const previousWorkspaceId = process.env.COORD_WORKSPACE_ID; + const previousSessionName = process.env.COORD_SESSION_NAME; + process.env.COORD_WORKSPACE_ID = 'ws_from_launcher'; + process.env.COORD_SESSION_NAME = 'launcher-track'; + try { + const result = await resolveWorkspaceContext({}); + assert.equal(result.workspaceId, 'ws_from_launcher'); + assert.equal(result.sessionName, 'launcher-track'); + } finally { + if (previousWorkspaceId === undefined) delete process.env.COORD_WORKSPACE_ID; + else process.env.COORD_WORKSPACE_ID = previousWorkspaceId; + if (previousSessionName === undefined) delete process.env.COORD_SESSION_NAME; + else process.env.COORD_SESSION_NAME = previousSessionName; + } +}); + test('.coord/.active containing "billing-refactor\\n" resolves to that session name', async () => { const dir = makeTempRepo(); try { diff --git a/tests/integration/cli-flow.test.mjs b/tests/integration/cli-flow.test.mjs index c65f133..9f99ed8 100644 --- a/tests/integration/cli-flow.test.mjs +++ b/tests/integration/cli-flow.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { apiGet, createTempGitRepo, + getFreePort, parseCreatedProjectId, parseStartedWorkspaceId, pathsEqual, @@ -22,12 +23,15 @@ test('CLI init → project create → start → status against a live daemon', a await daemon.stop(); }); - const init = runCli(['init', '--first-name', 'Ada', '--last-name', 'Lovelace', '--agent', 'cursor'], { + const init = runCli(['init', '--first-name', 'Ada', '--last-name', 'Lovelace', '--agent', 'cursor', '--project-name', 'Integration App'], { repoRoot, baseUrl: daemon.baseUrl }); assert.equal(init.exitCode, 0, init.stderr || init.stdout); assert.match(init.stdout, /Initialized Coord for Ada Lovelace/); + assert.match(init.stdout, /Created project "Integration App"/); + assert.match(init.stdout, /Next: coord work /); + const projectId = parseCreatedProjectId(init.stdout); const profileAfterInit = await apiGet('/user/profile', { repoRoot, baseUrl: daemon.baseUrl }); assert.equal(profileAfterInit.response.status, 200); @@ -47,14 +51,6 @@ test('CLI init → project create → start → status against a live daemon', a assert.equal(initAgain.exitCode, 0, initAgain.stderr || initAgain.stdout); assert.match(initAgain.stdout, /already initialized for Ada Lovelace/); - const create = runCli( - ['project', 'create', '--name', 'Integration App', '--description', 'CLI integration fixture'], - { repoRoot, baseUrl: daemon.baseUrl } - ); - assert.equal(create.exitCode, 0, create.stderr || create.stdout); - assert.match(create.stdout, /Created project "Integration App"/); - const projectId = parseCreatedProjectId(create.stdout); - const profileAfterCreate = await apiGet('/user/profile', { repoRoot, baseUrl: daemon.baseUrl }); assert.equal(profileAfterCreate.body.data.profile.defaultProjectId, projectId); @@ -117,24 +113,52 @@ test('CLI start picks the only project when --project is omitted', async (t) => await daemon.stop(); }); - runCli(['init', '--first-name', 'Grace', '--last-name', 'Hopper'], { repoRoot, baseUrl: daemon.baseUrl }); - - const create = runCli(['project', 'create', '--name', 'Solo Project'], { + const init = runCli(['init', '--first-name', 'Grace', '--last-name', 'Hopper', '--project-name', 'Solo Project'], { repoRoot, baseUrl: daemon.baseUrl }); - assert.equal(create.exitCode, 0, create.stderr || create.stdout); - parseCreatedProjectId(create.stdout); + assert.equal(init.exitCode, 0, init.stderr || init.stdout); + parseCreatedProjectId(init.stdout); - const track = runCli(['start', 'solo-track'], { repoRoot, baseUrl: daemon.baseUrl }); - assert.equal(track.exitCode, 0, track.stderr || track.stdout); - assert.match(track.stdout, /Started session "solo-track"/); + const work = runCli(['work', 'solo-track', '--no-launch'], { repoRoot, baseUrl: daemon.baseUrl }); + assert.equal(work.exitCode, 0, work.stderr || work.stdout); + assert.match(work.stdout, /Started session "solo-track"/); + assert.match(work.stdout, /Ready on "solo-track"/); const status = runCli(['status'], { repoRoot, baseUrl: daemon.baseUrl }); assert.equal(status.exitCode, 0, status.stderr || status.stdout); assert.match(status.stdout, /solo-track → proj_/); }); +test('coord init auto-starts its daemon and creates a folder-named project', async (t) => { + const repoRoot = await createTempGitRepo(); + const port = await getFreePort(); + t.after(async () => { + runCli(['daemon', 'stop'], { repoRoot, cwd: repoRoot }); + await removeTempDir(repoRoot); + }); + + const previousPort = process.env.COORD_DAEMON_PORT; + process.env.COORD_DAEMON_PORT = String(port); + try { + const init = runCli( + ['init', '--first-name', 'Linus', '--last-name', 'Torvalds', '--agent', 'codex'], + { repoRoot, cwd: repoRoot } + ); + assert.equal(init.exitCode, 0, init.stderr || init.stdout); + assert.match(init.stdout, /Started the Coord daemon/); + assert.match(init.stdout, /Created project "/); + assert.match(init.stdout, /Coord is ready/); + + const work = runCli(['work', 'first-track', '--no-launch'], { repoRoot, cwd: repoRoot }); + assert.equal(work.exitCode, 0, work.stderr || work.stdout); + assert.match(work.stdout, /Ready on "first-track"/); + } finally { + if (previousPort === undefined) delete process.env.COORD_DAEMON_PORT; + else process.env.COORD_DAEMON_PORT = previousPort; + } +}); + test('CLI fails fast when daemon is unreachable', async (t) => { const repoRoot = await createTempGitRepo(); t.after(async () => { diff --git a/tests/integration/helpers.mjs b/tests/integration/helpers.mjs index 7963ccb..6635554 100644 --- a/tests/integration/helpers.mjs +++ b/tests/integration/helpers.mjs @@ -105,14 +105,19 @@ export async function startTestDaemon(repoRoot) { } export function runCli(args, { repoRoot, baseUrl, cwd }) { + const env = { + ...process.env, + COORD_REPO_ROOT: repoRoot + }; + if (baseUrl) { + env.COORD_DAEMON_URL = baseUrl; + } else { + delete env.COORD_DAEMON_URL; + } try { const stdout = execFileSync(process.execPath, [CLI_BIN, ...args], { cwd: cwd ?? repoRoot, - env: { - ...process.env, - COORD_DAEMON_URL: baseUrl, - COORD_REPO_ROOT: repoRoot - }, + env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });