Skip to content

Commit c24d024

Browse files
justinhelmerJustin Helmerclaude
authored
feat(map): add polylane map to run the first mapping via the user's coding agent (#41)
Adds a `headlessRun` recipe to the agent registry and a `polylane map` command that launches the user's primary coding agent headlessly to map the current repository (prompt: "Map this repository with Polylane"). This is what the 0.2.x installer's "Or we can run it for you now?" step calls, so the user never has to open their agent and paste the prompt by hand. - `HeadlessRun` recipe ({ bin, args, env }) added to AgentSetup for the 8 agents with a documented scriptable one-shot + auto-approve: claude (-p --permission-mode bypassPermissions), codex (exec --full-auto), cursor (cursor-agent -p --force), gemini (-p --yolo), opencode (run), goose (run --no-session -t, GOOSE_MODE=auto), pi (-p), cline (--yolo). IDE/extension surfaces (warp, roo, windsurf, zed, vscode) have no recipe. - `polylane map` resolves the agent (--agent flag > configured `agent` from #38 > sole installed > interactive pick), verifies the binary is on PATH, and falls back to any installed sibling CLI agent when the primary is IDE-only, so a Windsurf/Zed user still maps through e.g. Claude Code. `--dry-run` prints the command; no runnable agent prints the manual instruction and exits 0. Registered as a no-auth command (like `setup`). - 22 new tests (recipe coverage, sibling fallback, PATH probe); `map` added to the resource-group and no-auth lists. Part of unified onboarding (coreplanelabs/nominal#1165). Needs a CLI release after merge for the installer to pick it up. Co-authored-by: Justin Helmer <justin@coreplane.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent df06e0f commit c24d024

7 files changed

Lines changed: 297 additions & 0 deletions

File tree

‎src/agents/registry.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export const MCP_SERVER_NAME = 'polylane';
1111
export const MCP_SERVER_URL = process.env.POLYLANE_MCP_URL || 'https://mcp.polylane.com/mcp';
1212
export const SKILL_DIRECTORY_NAME = 'polylane-cli';
1313

14+
/** The prompt handed to a coding agent to map the current repository. */
15+
export const MAPPING_PROMPT = 'Map this repository with Polylane';
16+
1417
export type WriteAction = 'created' | 'updated' | 'unchanged' | 'skipped';
1518

1619
export interface WriteOutcome {
@@ -175,12 +178,28 @@ export function upsertGooseExtension(path: string, dryRun = false): WriteOutcome
175178
return { label, path, action: 'updated' };
176179
}
177180

181+
/**
182+
* How to launch an agent headlessly for a one-shot, auto-approved run of a
183+
* prompt. Absent for agents with no scriptable headless entry (IDE/extension
184+
* surfaces like Windsurf, Zed, Roo, VS Code).
185+
*/
186+
export interface HeadlessRun {
187+
/** The executable to invoke; must resolve on PATH for the recipe to run. */
188+
bin: string;
189+
/** Build the full argv (excluding `bin`) for a one-shot run of `prompt`. */
190+
args(prompt: string): string[];
191+
/** Extra environment needed to run non-interactively (merged over process.env). */
192+
env?: Record<string, string>;
193+
}
194+
178195
export interface AgentSetup {
179196
id: string;
180197
name: string;
181198
detect(home: string): boolean;
182199
user(home: string, dryRun: boolean): WriteOutcome[];
183200
project?(projectDir: string, dryRun: boolean): WriteOutcome[];
201+
/** How to run this agent headlessly, when it has a scriptable one-shot mode. */
202+
headlessRun?: HeadlessRun;
184203
}
185204

186205
function skillFile(baseDir: string): string {
@@ -191,6 +210,7 @@ export const AGENTS: AgentSetup[] = [
191210
{
192211
id: 'claude',
193212
name: 'Claude Code',
213+
headlessRun: { bin: 'claude', args: (prompt) => ['-p', prompt, '--permission-mode', 'bypassPermissions'] },
194214
detect: (home) => existsSync(join(home, '.claude')) || existsSync(join(home, '.claude.json')),
195215
user: (home, dryRun) => [
196216
writeSkillFile(skillFile(join(home, '.claude')), dryRun),
@@ -204,6 +224,7 @@ export const AGENTS: AgentSetup[] = [
204224
{
205225
id: 'cursor',
206226
name: 'Cursor',
227+
headlessRun: { bin: 'cursor-agent', args: (prompt) => ['-p', prompt, '--force'] },
207228
detect: (home) => existsSync(join(home, '.cursor')),
208229
user: (home, dryRun) => [
209230
writeSkillFile(skillFile(join(home, '.cursor')), dryRun),
@@ -217,6 +238,7 @@ export const AGENTS: AgentSetup[] = [
217238
{
218239
id: 'opencode',
219240
name: 'OpenCode',
241+
headlessRun: { bin: 'opencode', args: (prompt) => ['run', prompt] },
220242
detect: (home) => existsSync(join(home, '.config', 'opencode')),
221243
user: (home, dryRun) => [
222244
writeSkillFile(skillFile(join(home, '.config', 'opencode')), dryRun),
@@ -240,6 +262,7 @@ export const AGENTS: AgentSetup[] = [
240262
{
241263
id: 'codex',
242264
name: 'Codex CLI',
265+
headlessRun: { bin: 'codex', args: (prompt) => ['exec', '--full-auto', prompt] },
243266
detect: (home) => existsSync(join(home, '.codex')),
244267
user: (home, dryRun) => [
245268
writeSkillFile(skillFile(join(home, '.codex')), dryRun),
@@ -258,6 +281,7 @@ export const AGENTS: AgentSetup[] = [
258281
{
259282
id: 'pi',
260283
name: 'Pi',
284+
headlessRun: { bin: 'pi', args: (prompt) => ['-p', prompt] },
261285
detect: (home) => existsSync(join(home, '.pi')),
262286
user: (home, dryRun) => [
263287
writeSkillFile(skillFile(join(home, '.pi', 'agent')), dryRun),
@@ -284,6 +308,7 @@ export const AGENTS: AgentSetup[] = [
284308
{
285309
id: 'cline',
286310
name: 'Cline',
311+
headlessRun: { bin: 'cline', args: (prompt) => ['--yolo', prompt] },
287312
// Two Cline surfaces share one config format: the VS Code extension
288313
// (globalStorage) and the Cline CLI (~/.cline). Write to whichever exists
289314
// so we never create VS Code's storage tree for an uninstalled extension.
@@ -324,12 +349,14 @@ export const AGENTS: AgentSetup[] = [
324349
{
325350
id: 'goose',
326351
name: 'Goose',
352+
headlessRun: { bin: 'goose', args: (prompt) => ['run', '--no-session', '-t', prompt], env: { GOOSE_MODE: 'auto' } },
327353
detect: (home) => existsSync(join(home, '.config', 'goose')),
328354
user: (home, dryRun) => [upsertGooseExtension(join(home, '.config', 'goose', 'config.yaml'), dryRun)],
329355
},
330356
{
331357
id: 'gemini',
332358
name: 'Gemini CLI',
359+
headlessRun: { bin: 'gemini', args: (prompt) => ['-p', prompt, '--yolo'] },
333360
detect: (home) => existsSync(join(home, '.gemini')),
334361
user: (home, dryRun) => [
335362
upsertJsonEntry(join(home, '.gemini', 'settings.json'), ['mcpServers', MCP_SERVER_NAME], GEMINI_SERVER_ENTRY, dryRun),

‎src/commands/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { configCommands } from './config';
44
import { helpCommand } from './help';
55
import { scanCommand } from './scan';
66
import { setupCommand } from './setup';
7+
import { mapCommand } from './map';
78
import { updateCommand } from './update';
89
import { feedCommands } from './feed';
910
import { issueCommands } from './issue';
@@ -52,6 +53,7 @@ export function registerAllCommands(): void {
5253
helpCommand,
5354
scanCommand,
5455
setupCommand,
56+
mapCommand,
5557
updateCommand,
5658
];
5759

‎src/commands/map.ts‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { spawn } from 'node:child_process';
2+
import { existsSync } from 'node:fs';
3+
import { homedir } from 'node:os';
4+
import { delimiter, join } from 'node:path';
5+
import type { Command } from '../command';
6+
import type { Config } from '../config/schema';
7+
import type { GlobalFlags } from '../types/flags';
8+
import { AGENTS, AGENT_IDS, MAPPING_PROMPT, agentById, type AgentSetup, type HeadlessRun } from '../agents/registry';
9+
import { isInteractive } from '../utils/env';
10+
import { promptSelect } from '../utils/prompt';
11+
import { CLIError } from '../errors/base';
12+
import { ExitCode } from '../errors/codes';
13+
14+
/** Whether `bin` resolves to an executable on PATH. */
15+
export function onPath(bin: string): boolean {
16+
const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
17+
const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
18+
return dirs.some((dir) => exts.some((ext) => existsSync(join(dir, bin + ext))));
19+
}
20+
21+
interface Runnable {
22+
agent: AgentSetup;
23+
run: HeadlessRun;
24+
}
25+
26+
/**
27+
* Pick the agent that will actually run the map. The primary agent runs it when
28+
* it has a headless recipe whose binary is installed. Otherwise any other
29+
* installed agent with a runnable recipe is a fallback — the map only needs an
30+
* agent wired to the authed MCP, not the primary one — so a user whose primary
31+
* is IDE-only (Windsurf/Zed/Roo/VS Code) can still map through a sibling CLI.
32+
*/
33+
export function resolveRunnable(
34+
primary: AgentSetup | undefined,
35+
installed: AgentSetup[],
36+
isOnPath: (bin: string) => boolean = onPath
37+
): { runnable?: Runnable; viaSibling: boolean } {
38+
if (primary?.headlessRun && isOnPath(primary.headlessRun.bin)) {
39+
return { runnable: { agent: primary, run: primary.headlessRun }, viaSibling: false };
40+
}
41+
const sibling = installed.find(
42+
(a) => a.id !== primary?.id && a.headlessRun && isOnPath(a.headlessRun.bin)
43+
);
44+
if (sibling?.headlessRun) {
45+
return { runnable: { agent: sibling, run: sibling.headlessRun }, viaSibling: Boolean(primary) };
46+
}
47+
return { viaSibling: false };
48+
}
49+
50+
function runChild(bin: string, argv: string[], env?: Record<string, string>): Promise<number> {
51+
return new Promise((resolve, reject) => {
52+
const child = spawn(bin, argv, {
53+
stdio: 'inherit',
54+
cwd: process.cwd(),
55+
env: { ...process.env, ...(env ?? {}) },
56+
});
57+
child.on('error', (err) =>
58+
reject(new CLIError(`Failed to launch ${bin}: ${(err as Error).message}`, ExitCode.GENERAL))
59+
);
60+
child.on('close', (code) => resolve(code ?? 0));
61+
});
62+
}
63+
64+
export const mapCommand: Command = {
65+
name: 'map',
66+
description: 'Map this repository into your workspace using your coding agent',
67+
usage: 'polylane map [--agent <id>]',
68+
options: [
69+
{
70+
flag: '--agent <id>',
71+
description: 'Coding agent to run the map (defaults to your configured agent)',
72+
type: 'string',
73+
},
74+
],
75+
examples: ['polylane map', 'polylane map --agent codex'],
76+
async execute(config: Config, _flags: GlobalFlags, args: Record<string, unknown>): Promise<void> {
77+
const home = homedir();
78+
const installed = AGENTS.filter((a) => a.detect(home));
79+
80+
const requestedId = typeof args.agent === 'string' ? args.agent : undefined;
81+
if (requestedId && !agentById(requestedId)) {
82+
throw new CLIError(
83+
`Unknown agent: "${requestedId}"`,
84+
ExitCode.USAGE,
85+
`Supported agents: ${AGENT_IDS.join(', ')}`
86+
);
87+
}
88+
89+
// Resolve the primary agent: explicit flag > configured choice > the only
90+
// installed agent > interactive pick among installed agents.
91+
let primary = requestedId
92+
? agentById(requestedId)
93+
: config.agent
94+
? agentById(config.agent)
95+
: undefined;
96+
if (!primary && installed.length === 1) {
97+
primary = installed[0];
98+
} else if (!primary && installed.length > 1 && isInteractive(config.nonInteractive)) {
99+
const runnable = installed.filter((a) => a.headlessRun && onPath(a.headlessRun.bin));
100+
const choices = runnable.length ? runnable : installed;
101+
const id = await promptSelect(
102+
{ nonInteractive: config.nonInteractive },
103+
'Which coding agent should map this repository?',
104+
choices.map((a) => ({ value: a.id, label: a.name }))
105+
);
106+
primary = agentById(id);
107+
}
108+
109+
const { runnable, viaSibling } = resolveRunnable(primary, installed);
110+
111+
if (!runnable) {
112+
// Nothing installed can run headlessly — hand off the manual instruction.
113+
const name = primary?.name ?? 'your coding agent';
114+
process.stderr.write(`Open ${name} in this repository and ask: "${MAPPING_PROMPT}"\n`);
115+
return;
116+
}
117+
118+
const { agent, run } = runnable;
119+
const argv = run.args(MAPPING_PROMPT);
120+
121+
if (viaSibling && primary && primary.id !== agent.id) {
122+
process.stderr.write(
123+
`${primary.name} can't be launched headlessly; running the map with ${agent.name} instead.\n`
124+
);
125+
}
126+
127+
if (config.dryRun) {
128+
process.stderr.write(`Would run: ${run.bin} ${argv.join(' ')}\n`);
129+
return;
130+
}
131+
132+
process.stderr.write(`Mapping this repository with ${agent.name}...\n`);
133+
const code = await runChild(run.bin, argv, run.env);
134+
if (code !== 0) {
135+
throw new CLIError(
136+
`${agent.name} exited with code ${code} before finishing the map`,
137+
ExitCode.GENERAL,
138+
`Run it yourself: open ${agent.name} and ask "${MAPPING_PROMPT}"`
139+
);
140+
}
141+
},
142+
};

‎src/main.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const NO_AUTH_COMMANDS = new Set([
3333
'help',
3434
'scan',
3535
'setup',
36+
'map',
3637
'update',
3738
'version',
3839
'api list',

‎src/registry.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const RESOURCE_ORDER: Record<string, ResourceGroup> = {
3434
workspace: { name: 'workspace', description: 'Workspaces', order: 70 },
3535
auth: { name: 'auth', description: 'Authentication (login, status, logout)', order: 80 },
3636
setup: { name: 'setup', description: 'Wire the CLI into coding agents (agent skill + MCP server)', order: 85 },
37+
map: { name: 'map', description: 'Map this repository into your workspace using your coding agent', order: 86 },
3738
config: { name: 'config', description: 'CLI configuration', order: 90 },
3839
telemetry: { name: 'telemetry', description: 'Anonymous usage telemetry (status/enable/disable)', order: 95 },
3940
api: { name: 'api', description: 'Raw API access (advanced)', order: 100 },

0 commit comments

Comments
 (0)