diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 00b58ad..ed59732 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -11,9 +11,6 @@ export const MCP_SERVER_NAME = 'polylane'; export const MCP_SERVER_URL = process.env.POLYLANE_MCP_URL || 'https://mcp.polylane.com/mcp'; export const SKILL_DIRECTORY_NAME = 'polylane-cli'; -/** The prompt handed to a coding agent to map the current repository. */ -export const MAPPING_PROMPT = 'Map this repository with Polylane'; - export type WriteAction = 'created' | 'updated' | 'unchanged' | 'skipped'; export interface WriteOutcome { @@ -279,28 +276,12 @@ export function upsertGooseExtension(path: string, dryRun = false): WriteOutcome return { label, path, action: 'updated' }; } -/** - * How to launch an agent headlessly for a one-shot, auto-approved run of a - * prompt. Absent for agents with no scriptable headless entry (IDE/extension - * surfaces like Windsurf, Zed, Roo, VS Code). - */ -export interface HeadlessRun { - /** The executable to invoke; must resolve on PATH for the recipe to run. */ - bin: string; - /** Build the full argv (excluding `bin`) for a one-shot run of `prompt`. */ - args(prompt: string): string[]; - /** Extra environment needed to run non-interactively (merged over process.env). */ - env?: Record; -} - export interface AgentSetup { id: string; name: string; detect(home: string): boolean; user(home: string, dryRun: boolean): WriteOutcome[]; project?(projectDir: string, dryRun: boolean): WriteOutcome[]; - /** How to run this agent headlessly, when it has a scriptable one-shot mode. */ - headlessRun?: HeadlessRun; } function skillFile(baseDir: string): string { @@ -311,7 +292,6 @@ export const AGENTS: AgentSetup[] = [ { id: 'claude', name: 'Claude Code', - headlessRun: { bin: 'claude', args: (prompt) => ['-p', prompt, '--permission-mode', 'bypassPermissions'] }, detect: (home) => hasAgentFootprint(join(home, '.claude')) || existsSync(join(home, '.claude.json')), user: (home, dryRun) => [ writeSkillFile(skillFile(join(home, '.claude')), dryRun), @@ -325,7 +305,6 @@ export const AGENTS: AgentSetup[] = [ { id: 'cursor', name: 'Cursor', - headlessRun: { bin: 'cursor-agent', args: (prompt) => ['-p', prompt, '--force'] }, detect: (home) => hasAgentFootprint(join(home, '.cursor')), user: (home, dryRun) => [ writeSkillFile(skillFile(join(home, '.cursor')), dryRun), @@ -339,7 +318,6 @@ export const AGENTS: AgentSetup[] = [ { id: 'opencode', name: 'OpenCode', - headlessRun: { bin: 'opencode', args: (prompt) => ['run', prompt] }, detect: (home) => hasAgentFootprint(join(home, '.config', 'opencode')), user: (home, dryRun) => [ writeSkillFile(skillFile(join(home, '.config', 'opencode')), dryRun), @@ -363,7 +341,6 @@ export const AGENTS: AgentSetup[] = [ { id: 'codex', name: 'Codex CLI', - headlessRun: { bin: 'codex', args: (prompt) => ['exec', '--full-auto', prompt] }, detect: (home) => hasAgentFootprint(join(home, '.codex')), user: (home, dryRun) => [ writeSkillFile(skillFile(join(home, '.codex')), dryRun), @@ -382,7 +359,6 @@ export const AGENTS: AgentSetup[] = [ { id: 'pi', name: 'Pi', - headlessRun: { bin: 'pi', args: (prompt) => ['-p', prompt] }, detect: (home) => hasAgentFootprint(join(home, '.pi')), user: (home, dryRun) => [ writeSkillFile(skillFile(join(home, '.pi', 'agent')), dryRun), @@ -409,7 +385,6 @@ export const AGENTS: AgentSetup[] = [ { id: 'cline', name: 'Cline', - headlessRun: { bin: 'cline', args: (prompt) => ['--yolo', prompt] }, // Two Cline surfaces share one config format: the VS Code extension // (globalStorage) and the Cline CLI (~/.cline). Write to whichever exists // so we never create VS Code's storage tree for an uninstalled extension. @@ -450,14 +425,12 @@ export const AGENTS: AgentSetup[] = [ { id: 'goose', name: 'Goose', - headlessRun: { bin: 'goose', args: (prompt) => ['run', '--no-session', '-t', prompt], env: { GOOSE_MODE: 'auto' } }, detect: (home) => hasAgentFootprint(join(home, '.config', 'goose')), user: (home, dryRun) => [upsertGooseExtension(join(home, '.config', 'goose', 'config.yaml'), dryRun)], }, { id: 'gemini', name: 'Gemini CLI', - headlessRun: { bin: 'gemini', args: (prompt) => ['-p', prompt, '--yolo'] }, detect: (home) => hasAgentFootprint(join(home, '.gemini')), user: (home, dryRun) => [ upsertJsonEntry(join(home, '.gemini', 'settings.json'), ['mcpServers', MCP_SERVER_NAME], GEMINI_SERVER_ENTRY, dryRun), @@ -512,8 +485,6 @@ export const AGENTS: AgentSetup[] = [ }, ]; -export const AGENT_IDS = AGENTS.map((a) => a.id); - /** The agents installed for `home`, in registry order. The one detection the installer and setup share. */ export function detectedAgents(home: string): AgentSetup[] { return AGENTS.filter((a) => a.detect(home)); @@ -556,7 +527,3 @@ export function detectedAgentIds(home: string, namespace: AgentIdNamespace): str return ids; } -export function agentById(id: string): AgentSetup | undefined { - return AGENTS.find((a) => a.id === id); -} - diff --git a/src/commands/index.ts b/src/commands/index.ts index 73c5f3c..ffbc79a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,7 +3,6 @@ import { authCommands } from './auth'; import { configCommands } from './config'; import { helpCommand } from './help'; import { setupCommand } from './setup'; -import { mapCommand } from './map'; import { updateCommand } from './update'; import { feedCommands } from './feed'; import { issueCommands } from './issue'; @@ -49,7 +48,6 @@ export function registerAllCommands(): void { ...telemetryCommands, helpCommand, setupCommand, - mapCommand, updateCommand, ]; diff --git a/src/commands/map.ts b/src/commands/map.ts deleted file mode 100644 index 71eee2e..0000000 --- a/src/commands/map.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { delimiter, join } from 'node:path'; -import type { Command } from '../command'; -import type { Config } from '../config/schema'; -import type { GlobalFlags } from '../types/flags'; -import { AGENTS, AGENT_IDS, MAPPING_PROMPT, agentById, type AgentSetup, type HeadlessRun } from '../agents/registry'; -import { consoleBaseUrl } from '../auth/oauth'; -import { tryResolveCredential } from '../auth/resolver'; -import { PolylaneAPI } from '../generated/client'; -import { isInteractive } from '../utils/env'; -import { promptSelect } from '../utils/prompt'; -import { CLIError } from '../errors/base'; -import { ExitCode } from '../errors/codes'; - -/** - * Best-effort deep link to the workspace's topology page. Falls back to the - * console root when a workspace or credential can't be resolved (map is a - * no-auth command, so this must never throw or prompt). - */ -async function workspaceTopologyUrl(config: Config): Promise { - const base = consoleBaseUrl(config); - try { - if (!config.workspaceId) return base; - const credential = await tryResolveCredential(config); - if (!credential) return base; - const workspace = await new PolylaneAPI(config).workspacesGet(config.workspaceId); - return workspace?.slug ? `${base}/${workspace.slug}/topology` : base; - } catch { - return base; - } -} - -/** Whether `bin` resolves to an executable on PATH. */ -export function onPath(bin: string): boolean { - const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean); - const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; - return dirs.some((dir) => exts.some((ext) => existsSync(join(dir, bin + ext)))); -} - -interface Runnable { - agent: AgentSetup; - run: HeadlessRun; -} - -/** - * Pick the agent that will actually run the map. The primary agent runs it when - * it has a headless recipe whose binary is installed. Otherwise any other - * installed agent with a runnable recipe is a fallback — the map only needs an - * agent wired to the authed MCP, not the primary one — so a user whose primary - * is IDE-only (Windsurf/Zed/Roo/VS Code) can still map through a sibling CLI. - */ -export function resolveRunnable( - primary: AgentSetup | undefined, - installed: AgentSetup[], - isOnPath: (bin: string) => boolean = onPath -): { runnable?: Runnable; viaSibling: boolean } { - if (primary?.headlessRun && isOnPath(primary.headlessRun.bin)) { - return { runnable: { agent: primary, run: primary.headlessRun }, viaSibling: false }; - } - const sibling = installed.find( - (a) => a.id !== primary?.id && a.headlessRun && isOnPath(a.headlessRun.bin) - ); - if (sibling?.headlessRun) { - return { runnable: { agent: sibling, run: sibling.headlessRun }, viaSibling: Boolean(primary) }; - } - return { viaSibling: false }; -} - -function formatElapsed(ms: number): string { - const total = Math.floor(ms / 1000); - const m = Math.floor(total / 60); - const s = total % 60; - return m > 0 ? `${m}m ${s}s` : `${s}s`; -} - -/** - * Run the agent headlessly, capturing its output instead of streaming it. A - * coding agent's raw output is markdown and mostly tool calls, so dumping it - * into the terminal is a wall of noise; the rendered result lives in the - * workspace. We show a heartbeat while it runs and surface the captured log - * only if it fails. Returns the exit code and everything the agent printed. - */ -export function runMapping( - bin: string, - argv: string[], - env: Record | undefined, - onStart: () => void -): Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }> { - return new Promise((resolve, reject) => { - const child = spawn(bin, argv, { - stdio: ['ignore', 'pipe', 'pipe'], - cwd: process.cwd(), - env: { ...process.env, ...(env ?? {}) }, - }); - - // Keep at most the tail of the output so a long run can't grow unbounded; - // only the end matters when we surface it after a failure. Buffer, not - // string: decode once at the end so a multi-byte character split across - // chunk boundaries (or the tail cut) is sized and rendered correctly. - const MAX_CAPTURE = 64 * 1024; - let captured = Buffer.alloc(0); - const capture = (chunk: Buffer): void => { - captured = Buffer.concat([captured, chunk]); - if (captured.length > MAX_CAPTURE) captured = captured.subarray(captured.length - MAX_CAPTURE); - }; - child.stdout?.on('data', capture); - child.stderr?.on('data', capture); - - onStart(); - const started = Date.now(); - const tty = Boolean(process.stderr.isTTY); - // On a TTY the heartbeat overwrites one line every 5s; without a TTY (CI, - // piped, log capture) each tick is a fresh line, so tick far less often to - // avoid flooding logs on a long run. - const tickMs = tty ? 5000 : 30000; - const heartbeat = setInterval(() => { - const elapsed = formatElapsed(Date.now() - started); - if (tty) process.stderr.write(`\r\x1b[2mMapping… ${elapsed} elapsed\x1b[0m\x1b[K`); - else process.stderr.write(`Mapping… ${elapsed} elapsed\n`); - }, tickMs); - - const finish = (): void => { - clearInterval(heartbeat); - if (tty) process.stderr.write('\r\x1b[K'); - }; - - child.on('error', (err) => { - finish(); - reject(new CLIError(`Failed to launch ${bin}: ${(err as Error).message}`, ExitCode.GENERAL)); - }); - // close delivers (code, signal): a signal-killed agent has code === null, - // which must be treated as a failure, not mapped to 0. - child.on('close', (code, signal) => { - finish(); - resolve({ code, signal, output: captured.toString() }); - }); - }); -} - -export const mapCommand: Command = { - name: 'map', - description: 'Map this repository into your workspace using your coding agent', - usage: 'polylane map [--agent ]', - options: [ - { - flag: '--agent ', - description: 'Coding agent to run the map (defaults to an installed agent)', - type: 'string', - }, - ], - examples: ['polylane map', 'polylane map --agent codex'], - async execute(config: Config, _flags: GlobalFlags, args: Record): Promise { - const home = homedir(); - const installed = AGENTS.filter((a) => a.detect(home)); - - const requestedId = typeof args.agent === 'string' ? args.agent : undefined; - if (requestedId && !agentById(requestedId)) { - throw new CLIError( - `Unknown agent: "${requestedId}"`, - ExitCode.USAGE, - `Supported agents: ${AGENT_IDS.join(', ')}` - ); - } - - // Resolve the primary agent: explicit flag > the only installed agent > - // interactive pick among installed agents. - let primary = requestedId ? agentById(requestedId) : undefined; - if (!primary && installed.length === 1) { - primary = installed[0]; - } else if (!primary && installed.length > 1 && isInteractive(config.nonInteractive)) { - const runnable = installed.filter((a) => a.headlessRun && onPath(a.headlessRun.bin)); - const choices = runnable.length ? runnable : installed; - const id = await promptSelect( - { nonInteractive: config.nonInteractive }, - 'Which coding agent should map this repository?', - choices.map((a) => ({ value: a.id, label: a.name })) - ); - primary = agentById(id); - } - - const { runnable, viaSibling } = resolveRunnable(primary, installed); - - if (!runnable) { - // Nothing installed can run headlessly — hand off the manual instruction. - const name = primary?.name ?? 'your coding agent'; - process.stderr.write(`Open ${name} in this repository and ask: "${MAPPING_PROMPT}"\n`); - return; - } - - const { agent, run } = runnable; - const argv = run.args(MAPPING_PROMPT); - - if (viaSibling && primary && primary.id !== agent.id) { - process.stderr.write( - `${primary.name} can't be launched headlessly; running the map with ${agent.name} instead.\n` - ); - } - - if (config.dryRun) { - process.stderr.write(`Would run: ${run.bin} ${argv.join(' ')}\n`); - return; - } - - const { code, signal, output } = await runMapping(run.bin, argv, run.env, () => { - process.stderr.write( - `Mapping this repository with ${agent.name}. Your agent reads the repo, runs checks, and\n` + - `assembles the map locally; this usually takes a few minutes.\n` - ); - }); - - if (code !== 0) { - // Surface the tail of the captured output so a failure isn't silent. - const tail = output.trim().split('\n').slice(-20).join('\n'); - if (tail) process.stderr.write(`\n${tail}\n`); - const reason = signal ? `was terminated by ${signal}` : `exited with code ${code}`; - throw new CLIError( - `${agent.name} ${reason} before finishing the map`, - ExitCode.GENERAL, - `Run it yourself: open ${agent.name} and ask "${MAPPING_PROMPT}"` - ); - } - - const workspaceUrl = await workspaceTopologyUrl(config); - process.stderr.write( - `\n✓ Mapped this repository. View your topology, issues, and first-run thread:\n` + - ` ${workspaceUrl}\n` - ); - }, -}; diff --git a/src/main.ts b/src/main.ts index eaad348..1b391b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,7 +34,6 @@ const NO_AUTH_COMMANDS = new Set([ 'subscription plans', 'help', 'setup', - 'map', 'update', 'version', 'api list', diff --git a/src/registry.ts b/src/registry.ts index 13bb7e8..587a050 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -31,7 +31,6 @@ const RESOURCE_ORDER: Record = { subscription: { name: 'subscription', description: 'Plan and billing (plans, show, upgrade, manage)', order: 71 }, auth: { name: 'auth', description: 'Authentication (login, status, logout)', order: 80 }, setup: { name: 'setup', description: 'Wire the CLI into coding agents (agent skill + MCP server)', order: 85 }, - map: { name: 'map', description: 'Map this repository into your workspace using your coding agent', order: 86 }, config: { name: 'config', description: 'CLI configuration', order: 90 }, telemetry: { name: 'telemetry', description: 'Anonymous usage telemetry (status/enable/disable)', order: 95 }, api: { name: 'api', description: 'Raw API access (advanced)', order: 100 }, diff --git a/test/map.test.ts b/test/map.test.ts deleted file mode 100644 index 59f2288..0000000 --- a/test/map.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { onPath, resolveRunnable, runMapping } from '../src/commands/map'; -import { AGENTS, MAPPING_PROMPT, agentById, type AgentSetup } from '../src/agents/registry'; - -function fakeAgent(id: string, hasRecipe: boolean, bin = `${id}-bin`): AgentSetup { - return { - id, - name: id.toUpperCase(), - detect: () => true, - user: () => [], - ...(hasRecipe ? { headlessRun: { bin, args: (p: string) => ['-p', p] } } : {}), - }; -} - -describe('resolveRunnable', () => { - const yes = () => true; - const no = () => false; - - it('runs the primary when it has a recipe on PATH', () => { - const primary = fakeAgent('claude', true); - const { runnable, viaSibling } = resolveRunnable(primary, [primary], yes); - assert.equal(runnable?.agent.id, 'claude'); - assert.equal(viaSibling, false); - }); - - it('falls back to a sibling when the primary is IDE-only', () => { - const primary = fakeAgent('windsurf', false); - const sibling = fakeAgent('claude', true); - const { runnable, viaSibling } = resolveRunnable(primary, [primary, sibling], yes); - assert.equal(runnable?.agent.id, 'claude'); - assert.equal(viaSibling, true); - }); - - it('falls back to a sibling when the primary recipe binary is not installed', () => { - const primary = fakeAgent('cursor', true, 'cursor-agent'); - const sibling = fakeAgent('codex', true, 'codex'); - const onlyCodex = (bin: string) => bin === 'codex'; - const { runnable, viaSibling } = resolveRunnable(primary, [primary, sibling], onlyCodex); - assert.equal(runnable?.agent.id, 'codex'); - assert.equal(viaSibling, true); - }); - - it('returns nothing runnable when no installed agent has a recipe on PATH', () => { - const primary = fakeAgent('windsurf', false); - const { runnable } = resolveRunnable(primary, [primary, fakeAgent('zed', false)], yes); - assert.equal(runnable, undefined); - }); - - it('does not flag viaSibling when there is no primary at all', () => { - const only = fakeAgent('claude', true); - const { runnable, viaSibling } = resolveRunnable(undefined, [only], yes); - assert.equal(runnable?.agent.id, 'claude'); - assert.equal(viaSibling, false); - }); - - it('returns nothing when recipes exist but none are on PATH', () => { - const primary = fakeAgent('claude', true); - const { runnable } = resolveRunnable(primary, [primary], no); - assert.equal(runnable, undefined); - }); -}); - -describe('headlessRun recipes', () => { - const HEADLESS: Record = { - claude: { bin: 'claude', approve: 'bypassPermissions' }, - cursor: { bin: 'cursor-agent', approve: '--force' }, - opencode: { bin: 'opencode', approve: 'run' }, - codex: { bin: 'codex', approve: '--full-auto' }, - pi: { bin: 'pi', approve: '-p' }, - cline: { bin: 'cline', approve: '--yolo' }, - goose: { bin: 'goose', approve: '--no-session' }, - gemini: { bin: 'gemini', approve: '--yolo' }, - }; - const IDE_ONLY = ['warp', 'roo', 'windsurf', 'zed', 'vscode']; - - for (const [id, expected] of Object.entries(HEADLESS)) { - it(`${id} has a recipe that carries the prompt and auto-approves`, () => { - const agent = agentById(id); - assert.ok(agent?.headlessRun, `${id} should have a headlessRun recipe`); - assert.equal(agent!.headlessRun!.bin, expected.bin); - const argv = agent!.headlessRun!.args(MAPPING_PROMPT); - assert.ok(argv.includes(MAPPING_PROMPT), `${id} argv must include the mapping prompt`); - assert.ok(argv.includes(expected.approve), `${id} argv must include ${expected.approve}`); - }); - } - - it('goose runs non-interactively via GOOSE_MODE=auto', () => { - assert.equal(agentById('goose')?.headlessRun?.env?.GOOSE_MODE, 'auto'); - }); - - for (const id of IDE_ONLY) { - it(`${id} has no headless recipe (IDE/extension surface)`, () => { - assert.equal(agentById(id)?.headlessRun, undefined); - }); - } - - it('every recipe binary differs from a bare agent id where the CLI is named differently', () => { - // Cursor's binary is cursor-agent, not "cursor" — guard against a regression. - assert.equal(agentById('cursor')?.headlessRun?.bin, 'cursor-agent'); - }); -}); - -describe('runMapping', () => { - it('reports a signal-killed agent as a failure, not a success', async () => { - const res = await runMapping('sh', ['-c', 'kill -KILL $$'], undefined, () => {}); - // A signal death delivers code === null; it must not be mapped to 0. - assert.equal(res.code, null); - assert.ok(res.signal, 'expected a signal to be reported'); - }); - - it('reports a non-zero exit as the exit code', async () => { - const res = await runMapping('sh', ['-c', 'exit 3'], undefined, () => {}); - assert.equal(res.code, 3); - assert.equal(res.signal, null); - }); - - it('captures output and reports a clean exit', async () => { - const res = await runMapping('sh', ['-c', 'echo hello; exit 0'], undefined, () => {}); - assert.equal(res.code, 0); - assert.ok(res.output.includes('hello')); - }); -}); - -describe('onPath', () => { - it('finds an executable placed on a fake PATH', () => { - const dir = mkdtempSync(join(tmpdir(), 'polylane-onpath-')); - const bin = 'faux-agent'; - const file = join(dir, bin); - writeFileSync(file, '#!/bin/sh\n'); - chmodSync(file, 0o755); - const original = process.env.PATH; - process.env.PATH = dir; - try { - assert.equal(onPath(bin), true); - assert.equal(onPath('definitely-not-installed-xyz'), false); - } finally { - process.env.PATH = original; - } - }); -}); diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 5f00093..3cef9b6 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -35,9 +35,10 @@ describe('command resolution', () => { assert.equal(r.command.name, 'api call'); }); - it('returns null for unknown paths', () => { + it('returns null for unknown and removed paths', () => { assert.equal(registry.resolve(['unknown']), null); assert.equal(registry.resolve(['issue', 'nope']), null); + assert.equal(registry.resolve(['map']), null); }); it('has all resource groups', () => { @@ -54,7 +55,6 @@ describe('command resolution', () => { 'help', 'integration', 'issue', - 'map', 'memory', 'note', 'repo',