Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions src/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';

import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser';

import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { ensureDir } from '../utils/fs';
import { SKILL_MD } from '../generated/skill';

Expand Down Expand Up @@ -491,16 +489,3 @@ export function agentById(id: string): AgentSetup | undefined {
return AGENTS.find((a) => a.id === id);
}

export function isAgentId(id: string): boolean {
return AGENTS.some((a) => a.id === id);
}

export function validateAgentId(id: string): void {
if (!isAgentId(id)) {
throw new CLIError(
`Unknown agent: "${id}"`,
ExitCode.USAGE,
`Supported agents: ${AGENT_IDS.join(', ')}`
);
}
}
7 changes: 1 addition & 6 deletions src/commands/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import {
validateWorkspaceId,
} from '../../config/schema';
import { writeConfigFile } from '../../config/loader';
import { validateAgentId } from '../../agents/registry';
import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { requireArg } from '../helpers';

const VALID_KEYS = new Set(['domain', 'workspace_id', 'api_key', 'agent', 'output', 'timeout', 'telemetry', 'hints']);
const VALID_KEYS = new Set(['domain', 'workspace_id', 'api_key', 'output', 'timeout', 'telemetry', 'hints']);

function parseBooleanValue(key: string, value: string): boolean {
const truthy = ['1', 'true', 'yes', 'on', 'enabled'];
Expand Down Expand Up @@ -65,10 +64,6 @@ export const configSetCommand: Command = {
validateApiKey(value);
partial.api_key = value;
break;
case 'agent':
validateAgentId(value);
partial.agent = value;
break;
case 'output': {
validateOutput(value);
partial.output = value;
Expand Down
1 change: 0 additions & 1 deletion src/commands/config/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const configShowCommand: Command = {
const result = {
domain: config.domain,
workspaceId: config.workspaceId ?? null,
agent: config.agent ?? null,
output: config.output,
timeout: config.timeout,
hints: config.hints,
Expand Down
25 changes: 2 additions & 23 deletions src/commands/integration/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,23 +101,6 @@ export function resolveTypeOptions(category: string | undefined, typeFromFlag: b
return typeFromFlag ? TYPE_OPTIONS : filtered;
}

// When the user's primary local agent (config.agent, persisted by `polylane
// setup`) also exists as a cloud code agent, surface it first in its category
// and pre-highlight it in the picker. Exact id match only — the local
// registry and the integration types share ids where they overlap (cursor).
export function prioritizeCodeAgent(
options: typeof TYPE_OPTIONS,
localAgent: string | undefined
): { options: typeof TYPE_OPTIONS; initialValue: ConnectableType | undefined } {
const idx = options.findIndex((o) => o.category === 'code-agent' && o.value === localAgent);
if (idx < 0) return { options, initialValue: undefined };
const first = options.findIndex((o) => o.category === 'code-agent');
const reordered = [...options];
const [own] = reordered.splice(idx, 1);
reordered.splice(first, 0, { ...own!, hint: own!.hint.replace(/coding agent$/, 'your coding agent') });
return { options: reordered, initialValue: own!.value };
}

// Same site list the console offers; the flag accepts any value so orgs on
// sites not listed here (e.g. newer regions) are not locked out.
const DATADOG_SITES = [
Expand Down Expand Up @@ -798,10 +781,7 @@ export const integrationConnectCommand: Command = {
const typeFromFlag = getArgString(args, 'type') !== undefined;
const category = getArgString(args, 'category');
// --type always wins: the category filter only narrows the picker.
const { options: typeOptions, initialValue } = prioritizeCodeAgent(
resolveTypeOptions(category, typeFromFlag),
config.agent
);
const typeOptions = resolveTypeOptions(category, typeFromFlag);

if (shouldOfferCodeAgent(category, typeFromFlag, isInteractive(config.nonInteractive))) {
note(
Expand Down Expand Up @@ -840,8 +820,7 @@ export const integrationConnectCommand: Command = {
{ nonInteractive: config.nonInteractive },
'Which integration do you want to connect?',
typeOptions,
'Cancel',
initialValue
'Cancel'
);
if (type === BACK) break;
const outcome = await connectType(config, api, args, workspaceId, type, noBrowser);
Expand Down
12 changes: 4 additions & 8 deletions src/commands/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const mapCommand: Command = {
options: [
{
flag: '--agent <id>',
description: 'Coding agent to run the map (defaults to your configured agent)',
description: 'Coding agent to run the map (defaults to an installed agent)',
type: 'string',
},
],
Expand All @@ -164,13 +164,9 @@ export const mapCommand: Command = {
);
}

// Resolve the primary agent: explicit flag > configured choice > the only
// installed agent > interactive pick among installed agents.
let primary = requestedId
? agentById(requestedId)
: config.agent
? agentById(config.agent)
: undefined;
// 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)) {
Expand Down
55 changes: 3 additions & 52 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@ import type { Config } from '../config/schema';
import { tryResolveCredential } from '../auth/resolver';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { isInteractive } from '../utils/env';
import { promptSelect } from '../utils/prompt';
import { writeConfigFile } from '../config/loader';
import { getArgArray, getArgBoolean } from './helpers';
import { AGENTS, type AgentSetup, type WriteAction, type WriteOutcome } from '../agents/registry';
import { AGENTS, type WriteAction, type WriteOutcome } from '../agents/registry';

// The registry (agent table + config writers) lives in src/agents/registry.ts
// so the config loader can validate the stored agent id without importing a
// command module; re-exported here because this was its original home.
// The registry (agent table + config writers) lives in src/agents/registry.ts;
// re-exported here because this was its original home.
export {
AGENTS,
MCP_SERVER_NAME,
Expand All @@ -38,49 +34,6 @@ const ACTION_LABEL: Record<WriteAction, string> = {
skipped: 'skipped',
};

export type PrimaryAgentDecision =
| { kind: 'keep' }
| { kind: 'persist'; id: string }
| { kind: 'prompt'; candidates: AgentSetup[] };

// The primary agent is the one downstream handoffs address ("open <agent> and
// ask ..."); wiring is unaffected — every selected agent gets configured.
export function decidePrimaryAgent(
stored: string | undefined,
selected: AgentSetup[],
interactive: boolean
): PrimaryAgentDecision {
if (stored !== undefined) return { kind: 'keep' };
if (selected.length === 0) return { kind: 'keep' };
if (selected.length === 1) return { kind: 'persist', id: selected[0]!.id };
if (interactive) return { kind: 'prompt', candidates: selected };
return { kind: 'keep' };
}

async function settlePrimaryAgent(
config: Config,
selected: AgentSetup[],
say: (line: string) => void
): Promise<void> {
if (config.dryRun) return;
const decision = decidePrimaryAgent(config.agent, selected, isInteractive(config.nonInteractive));
if (decision.kind === 'keep') return;

let id: string;
if (decision.kind === 'persist') {
id = decision.id;
} else {
id = await promptSelect(
{ nonInteractive: config.nonInteractive },
'Which coding agent do you mainly use?',
decision.candidates.map((a) => ({ value: a.id, label: a.name })),
);
}
writeConfigFile({ agent: id });
const name = selected.find((a) => a.id === id)?.name ?? id;
say(`Primary coding agent: ${name} (change with \`polylane config set --key agent --value <id>\`)`);
}

export const setupCommand: Command = {
name: 'setup',
description: 'Wire the CLI into coding agents (agent skill + MCP server)',
Expand Down Expand Up @@ -162,8 +115,6 @@ export const setupCommand: Command = {
}
}

await settlePrimaryAgent(config, selected, say);

const credential = await tryResolveCredential(config);
if (credential) {
say('Signed in.');
Expand Down
8 changes: 0 additions & 8 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import type { GlobalFlags, OutputFormat } from '../types/flags';
import { readJsonFile, writeJsonFile } from '../utils/fs';
import { isStdoutTTY } from '../utils/env';
import { isAgentId } from '../agents/registry';

export function loadConfigFile(): RawConfig | null {
return readJsonFile<RawConfig>(CONFIG_FILE);
Expand Down Expand Up @@ -74,12 +73,6 @@ export function loadConfig(flags: GlobalFlags): Config {
const workspaceId = flags.workspace ?? env.POLYLANE_WORKSPACE_ID ?? file.workspace_id;
if (workspaceId !== undefined) validateWorkspaceId(workspaceId);

// Primary coding agent. Unknown ids are dropped rather than thrown so a
// stale stored value (e.g. an id removed from the registry) never bricks
// every invocation; `config set --key agent` is where strict validation happens.
const agentRaw = env.POLYLANE_AGENT ?? file.agent;
const agent = agentRaw !== undefined && isAgentId(agentRaw) ? agentRaw : undefined;

const timeout =
flags.timeout ?? parseEnvNumber(env.POLYLANE_TIMEOUT) ?? file.timeout ?? DEFAULT_TIMEOUT;
validateTimeout(timeout);
Expand Down Expand Up @@ -119,7 +112,6 @@ export function loadConfig(flags: GlobalFlags): Config {
apiKey,
domain,
workspaceId,
agent,
output,
timeout,
verbose,
Expand Down
3 changes: 0 additions & 3 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ export interface Config {
apiKey?: string;
domain: string;
workspaceId?: string;
/** Primary coding agent id (an AGENTS registry id, e.g. "claude", "cursor"). */
agent?: string;
output: OutputFormat;
timeout: number;
verbose: boolean;
Expand All @@ -26,7 +24,6 @@ export interface RawConfig {
api_key?: string;
domain?: string;
workspace_id?: string;
agent?: string;
output?: OutputFormat;
timeout?: number;
telemetry?: boolean;
Expand Down
12 changes: 0 additions & 12 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { validateDomain, validateOutput, validateTimeout, validateWorkspaceId } from '../src/config/schema';
import { CLIError } from '../src/errors/base';
import { AGENT_IDS, validateAgentId } from '../src/agents/registry';

describe('validateDomain', () => {
it('accepts valid hostnames', () => {
Expand Down Expand Up @@ -55,14 +54,3 @@ describe('validateWorkspaceId', () => {
assert.throws(() => validateWorkspaceId('acc_rii32455qptezc7467usm3f3hq31qkwp'), CLIError);
});
});

describe('validateAgentId', () => {
it('accepts every registry id', () => {
for (const id of AGENT_IDS) validateAgentId(id);
});

it('rejects unknown ids', () => {
assert.throws(() => validateAgentId('not-an-agent'));
assert.throws(() => validateAgentId(''));
});
});
39 changes: 0 additions & 39 deletions test/integration-connect-priority.test.ts

This file was deleted.

39 changes: 27 additions & 12 deletions test/loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import { describe, it, beforeEach, afterEach, after } from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../src/config/loader';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { GlobalFlags } from '../src/types/flags';

// Point HOME at a temp dir before importing any source module, so the loader
// reads this test's config file instead of the developer's real
// ~/.polylane/config.json. Same pattern as signup.test.ts.
const tempHome = mkdtempSync(join(tmpdir(), 'polylane-loader-test-'));
process.env.HOME = tempHome;
after(() => rmSync(tempHome, { recursive: true, force: true }));

const { loadConfig } = await import('../src/config/loader');

const configDir = join(tempHome, '.polylane');
const configFile = join(configDir, 'config.json');

describe('loadConfig', () => {
const originalEnv = { ...process.env };

Expand All @@ -13,8 +27,8 @@ describe('loadConfig', () => {
delete process.env.POLYLANE_TIMEOUT;
delete process.env.POLYLANE_OUTPUT;
delete process.env.POLYLANE_VERBOSE;
delete process.env.POLYLANE_AGENT;
delete process.env.POLYLANE_HINTS;
rmSync(configFile, { force: true });
});

afterEach(() => {
Expand Down Expand Up @@ -49,16 +63,17 @@ describe('loadConfig', () => {
assert.equal(config.verbose, true);
});

it('reads the primary agent from env', () => {
process.env.POLYLANE_AGENT = 'cursor';
const config = loadConfig({} as GlobalFlags);
assert.equal(config.agent, 'cursor');
});

it('drops an unknown agent id instead of throwing', () => {
process.env.POLYLANE_AGENT = 'not-an-agent';
it('silently tolerates unknown fields in the config file (e.g. a legacy agent key)', () => {
// Older CLI versions persisted a primary coding agent choice; existing
// config files still carry it. It must be ignored, never an error.
mkdirSync(configDir, { recursive: true });
writeFileSync(
configFile,
JSON.stringify({ domain: 'api.legacy.example.com', agent: 'cursor', some_future_key: true })
);
const config = loadConfig({} as GlobalFlags);
assert.equal(config.agent, undefined);
assert.equal(config.domain, 'api.legacy.example.com');
assert.ok(!('agent' in config));
});

it('hints default on', () => {
Expand Down
Loading
Loading