refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs
Some checks failed
CI/CD / lint (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / typecheck (pull_request) Successful in 3m20s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m37s
CI/CD / publish (pull_request) Has been skipped

The prime-agent switcher existed only as a 275-line string literal inside
prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap
that let a wrong ctx.ui.select() option shape ship in the pi extension. It now
lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig
checking it against the real @earendil-works/pi-coding-agent types, eslint
coverage and an embed-freshness test, matching pi and opencode.

The extraction was verified byte-identical before any edit, so the behaviour
shipped today is exactly what was captured. Linting it then found six problems
in code nothing had ever checked: object-truthiness null guards, a nullable
string conditional and a missing return type. All behaviour-preserving to fix,
but exactly the class of thing that ships silently when nothing is looking.

Also:

  - `config pi` gains --dry-run, the last agent without it.
  - The SessionStart hook installer now drops untagged duplicates of its own
    exact command — rows left behind before the marker existed, or by a suite
    that used to write into a real ~/.claude. Invisible in the UI; they just run
    the sync twice per session. A hook the user wrote is never touched, even one
    calling `mcpctl skills sync` with different flags.
  - docs/claude-integration.md and docs/prime-agent-extension.md, the two
    integrations that had no page.

prime-agent deliberately keeps its per-project MCP entry name rather than the
constant `mcpctl` claude and opencode now use: its switcher already unmounts the
previous project, so it never accumulates entries, and re-keying auth.json from
mcp:<project> to mcp:mcpctl would give up per-project token caching and needs a
migration. Documented as its own change rather than folded in here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
This commit is contained in:
Michal
2026-08-09 19:20:36 +01:00
parent b3a062ce28
commit a9fcd83ed8
15 changed files with 716 additions and 7 deletions

View File

@@ -104,3 +104,48 @@ describe('sessionhook', () => {
expect(settings.hooks.SessionStart).toHaveLength(1);
});
});
describe('untagged duplicates of the managed hook', () => {
let tmp2: string;
let settings: string;
beforeEach(async () => {
tmp2 = await mkdtemp(join(tmpdir(), 'mcpctl-hook-dupe-'));
settings = join(tmp2, 'settings.json');
});
afterEach(async () => { await rm(tmp2, { recursive: true, force: true }); });
it('removes an identical row left behind before the marker existed', async () => {
// Exactly the shape found in a real ~/.claude: one tagged row, one not.
// Invisible in the UI; it just runs the sync twice every session.
await writeFile(settings, JSON.stringify({
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet' }] },
{ hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }] },
],
},
}));
const { updated } = await installManagedSessionHook('mcpctl skills sync --quiet', settings);
expect(updated).toBe(true);
const parsed = JSON.parse(await readFile(settings, 'utf-8')) as {
hooks: { SessionStart: Array<{ hooks: Array<Record<string, unknown>> }> };
};
const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks);
expect(rows).toEqual([{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }]);
});
it('leaves a hook the user wrote alone, even one that also calls mcpctl', async () => {
await writeFile(settings, JSON.stringify({
hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'mcpctl skills sync --project mine' }] }],
},
}));
await installManagedSessionHook('mcpctl skills sync --quiet', settings);
const parsed = JSON.parse(await readFile(settings, 'utf-8')) as {
hooks: { SessionStart: Array<{ hooks: Array<{ command: string }> }> };
};
const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks).map((r) => r.command);
expect(rows).toContain('mcpctl skills sync --project mine');
});
});