2026-08-08 09:34:15 +01:00
import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest' ;
fix(cli): close third review — token collision, migration, ownership
Round 2 fixed the first review but introduced regressions of its own, all
of which only bite against state written by the previously installed build.
`config prime-agent`:
- Mint each credential under a unique `prime-agent-<stamp>` name again.
`McpToken` is unique on (name, projectId) and revoke is a soft delete, so
round 2's fixed `prime-agent` name could only ever be minted once per
project — and the revoke-first ordering destroyed the working credential
before discovering the mint would fail.
- Provision the credential BEFORE touching settings.json. Registering the
new project unmounts the previously active one, so a failed mint must not
be able to leave prime-agent with no working project at all. The command
now aborts with settings.json untouched.
- Retire only the token this auth.json actually held, once its replacement
is stored. Sweeping every `prime-agent*` token for the project would
revoke the credential another install (or a custom --output run) is
using; anything else that looks orphaned is reported, not deleted.
- Validate a pre-existing credential instead of trusting its presence: a
revoked or expired token used to short-circuit provisioning and leave
prime-agent broken while the command reported success. Matched by
tokenPrefix against the project's active tokens, so the secret is never
sent. Fails open when the API can't be consulted.
- Actually write auth.json 0600. `writeFile`'s mode is ignored for an
existing file and prime-agent creates auth.json itself at 0644, so chmod
after writing.
- Recognise the untagged mcpServers entries older CLIs wrote (canonical
proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts
them instead of leaving two gateways live. Hand-configured servers have
no such credential and are still preserved. Same rule in the `/mcpctl`
switcher's active-project lookup.
- Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the
extension runs from whatever directory prime-agent was started in, and
was silently re-scoping that repo's `.mcpctl-project`.
`skills sync --agent prime-agent`:
- Record ownership from the skill's own scope, not the syncing project's.
Globals were being pinned to whichever project happened to sync them,
after which every other project refused to update them forever.
- Never adopt legacy, ownership-less state into the current scope. Round 2
did, which deleted the other project's skills on the first sync after
upgrading. Such entries are attributed to the project that last wrote the
state file, and left alone when that isn't the project syncing now.
- Close the overwrite-guard bypass: a sync with no project, or a global
landing on a project-owned name, could still clobber and re-own a
tracked skill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
import { writeFileSync , readFileSync , mkdtempSync , rmSync , existsSync , statSync , chmodSync } from 'node:fs' ;
2026-08-08 09:34:15 +01:00
import { join } from 'node:path' ;
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
import { tmpdir , homedir } from 'node:os' ;
2026-08-08 09:34:15 +01:00
import { createConfigCommand } from '../../src/commands/config.js' ;
import type { ApiClient } from '../../src/api-client.js' ;
import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js' ;
function mockClient ( ) : ApiClient {
return {
get : vi . fn ( async ( ) = > ( { } ) ) ,
post : vi.fn ( async ( ) = > ( { token : 'impersonated-tok' , user : { email : 'other@test.com' } } ) ) ,
put : vi.fn ( async ( ) = > ( { } ) ) ,
delete : vi . fn ( async ( ) = > { } ) ,
} as unknown as ApiClient ;
}
describe ( 'config prime-agent' , ( ) = > {
let client : ReturnType < typeof mockClient > ;
let output : string [ ] ;
let tmpDir : string ;
const log = ( . . . args : string [ ] ) = > output . push ( args . join ( ' ' ) ) ;
let prevCwd : string ;
beforeEach ( ( ) = > {
client = mockClient ( ) ;
output = [ ] ;
tmpDir = mkdtempSync ( join ( tmpdir ( ) , 'mcpctl-config-prime-agent-' ) ) ;
// config prime-agent writes the .mcpctl-project marker into cwd, so run
// every test from an isolated temp dir to avoid polluting the repo.
prevCwd = process . cwd ( ) ;
process . chdir ( tmpDir ) ;
} ) ;
afterEach ( ( ) = > {
process . chdir ( prevCwd ) ;
process . exitCode = 0 ;
rmSync ( tmpDir , { recursive : true , force : true } ) ;
} ) ;
it ( 'requires --project' , async ( ) = > {
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--skip-skills' ] , { from : 'user' } ) ;
expect ( output . join ( '\n' ) ) . toContain ( '--project is required' ) ;
expect ( process . exitCode ) . toBe ( 1 ) ;
} ) ;
it ( 'writes proxy MCP entry into prime-agent settings.json' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'homeautomation' , '-o' , settingsPath , '--skip-skills' ] , { from : 'user' } ) ;
const written = JSON . parse ( readFileSync ( settingsPath , 'utf-8' ) ) ;
expect ( written . mcpServers [ 'homeautomation' ] ) . toEqual ( {
type : 'http' ,
url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/homeautomation/mcp ` ,
fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Addresses the second round of `config prime-agent` review (10 findings).
auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin:
- loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and-
rewrite, so one syntax error can no longer destroy the provider API key and
every other project's credential. hasPrimeAgentAuth shares that guarantee.
- writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead
of the default umask — bearer tokens are no longer world-readable on first
creation.
state ownership (commands/skills.ts) — the ownership model edge cases:
- orphan-removal guard now normalises a canonical scope (project name, or null
for globals; legacy undefined adopted to current scope) instead of comparing
null against undefined, so global-only syncs and pre-PR state can no longer
leave stale skills on disk forever.
- a same-named skill *tracked* to a different project is preserved (with a
warning) rather than silently overwritten in the shared flat tree.
- mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning
(prime-agent's HTTP gateway must not mutate shared mcpd project attachments);
this also makes the earlier dropped-attach concern explicit rather than silent.
single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts):
- registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes
other mcpctl-managed entries, so prime-agent has one *active* mcpctl project
while preserving untagged servers (hand-configured sre, websearch, etc).
- the /mcpctl extension now reads that tag as the single source of truth for the
active project, fixing the false short-circuit / no-op switch.
config.ts command:
- an explicit -p now updates a differing up-tree .mcpctl-project marker (scope
no longer silently reverts on the next sync), no-ops when it matches, and
still never scopes $HOME.
- a project left with no usable credential now exits non-zero (the /mcpctl
extension checks child exit status, so it no longer reports a successful
switch after provisioning failed).
- skills sync is treated as best-effort: settings+auth determine switch success,
so a skills error no longer falsely fails the switch.
- token minting now revokes prior active `prime-agent` tokens before creating a
fresh one (no more never-expiring token litter / lost-credential duplication).
Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit
code, single-active dedup preserving untagged sre, cross-project overwrite
preservation, and global-orphan removal on global-only sync.
2026-08-08 11:28:15 +01:00
mcpctlManaged : true ,
2026-08-08 09:34:15 +01:00
} ) ;
expect ( output . join ( '\n' ) ) . toContain ( 'homeautomation' ) ;
} ) ;
it ( 'merges with existing servers and preserves other settings' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( settingsPath , JSON . stringify ( {
defaultProvider : 'itaz' ,
mcpServers : {
sre : { type : 'http' , url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/sre/mcp ` } ,
} ,
} ) ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'proj-1' , '-o' , settingsPath , '--skip-skills' ] , { from : 'user' } ) ;
const written = JSON . parse ( readFileSync ( settingsPath , 'utf-8' ) ) ;
expect ( written . defaultProvider ) . toBe ( 'itaz' ) ; // untouched
expect ( written . mcpServers [ 'sre' ] ) . toBeDefined ( ) ; // preserved
expect ( written . mcpServers [ 'proj-1' ] ) . toEqual ( {
type : 'http' ,
url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/proj-1/mcp ` ,
fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Addresses the second round of `config prime-agent` review (10 findings).
auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin:
- loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and-
rewrite, so one syntax error can no longer destroy the provider API key and
every other project's credential. hasPrimeAgentAuth shares that guarantee.
- writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead
of the default umask — bearer tokens are no longer world-readable on first
creation.
state ownership (commands/skills.ts) — the ownership model edge cases:
- orphan-removal guard now normalises a canonical scope (project name, or null
for globals; legacy undefined adopted to current scope) instead of comparing
null against undefined, so global-only syncs and pre-PR state can no longer
leave stale skills on disk forever.
- a same-named skill *tracked* to a different project is preserved (with a
warning) rather than silently overwritten in the shared flat tree.
- mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning
(prime-agent's HTTP gateway must not mutate shared mcpd project attachments);
this also makes the earlier dropped-attach concern explicit rather than silent.
single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts):
- registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes
other mcpctl-managed entries, so prime-agent has one *active* mcpctl project
while preserving untagged servers (hand-configured sre, websearch, etc).
- the /mcpctl extension now reads that tag as the single source of truth for the
active project, fixing the false short-circuit / no-op switch.
config.ts command:
- an explicit -p now updates a differing up-tree .mcpctl-project marker (scope
no longer silently reverts on the next sync), no-ops when it matches, and
still never scopes $HOME.
- a project left with no usable credential now exits non-zero (the /mcpctl
extension checks child exit status, so it no longer reports a successful
switch after provisioning failed).
- skills sync is treated as best-effort: settings+auth determine switch success,
so a skills error no longer falsely fails the switch.
- token minting now revokes prior active `prime-agent` tokens before creating a
fresh one (no more never-expiring token litter / lost-credential duplication).
Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit
code, single-active dedup preserving untagged sre, cross-project overwrite
preservation, and global-orphan removal on global-only sync.
2026-08-08 11:28:15 +01:00
mcpctlManaged : true ,
2026-08-08 09:34:15 +01:00
} ) ;
} ) ;
it ( 'writes a project marker for later skills sync' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'sre' , '-o' , settingsPath , '--skip-skills' ] , { from : 'user' } ) ;
const markerPath = join ( tmpDir , '.mcpctl-project' ) ;
expect ( readFileSync ( markerPath , 'utf-8' ) . trim ( ) ) . toBe ( 'sre' ) ;
} ) ;
it ( '--dry-run prints the change without writing' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'proj-2' , '-o' , settingsPath , '--dry-run' ] , { from : 'user' } ) ;
expect ( output . join ( '\n' ) ) . toContain ( 'proj-2' ) ;
// No file should have been created.
expect ( exceptionSafeRead ( settingsPath ) ) . toBeNull ( ) ;
} ) ;
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
it ( 'does not call the API when --skip-skills and --token are given' , async ( ) = > {
2026-08-08 09:34:15 +01:00
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'proj-3' , '-o' , settingsPath , '--skip-skills' , '--token' , 'mcpctl_pat_test' ] , { from : 'user' } ) ;
2026-08-08 09:34:15 +01:00
expect ( client . get ) . not . toHaveBeenCalled ( ) ;
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
expect ( client . post ) . not . toHaveBeenCalled ( ) ;
2026-08-08 09:34:15 +01:00
} ) ;
it ( 'backward compat: prime-agent-generate still works' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent-generate' , '--project' , 'proj-1' , '-o' , settingsPath , '--skip-skills' ] , { from : 'user' } ) ;
const written = JSON . parse ( readFileSync ( settingsPath , 'utf-8' ) ) ;
expect ( written . mcpServers [ 'proj-1' ] ) . toBeDefined ( ) ;
} ) ;
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
it ( 'provisions auth.json by minting a project token' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'labctl' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
expect ( client . post ) . toHaveBeenCalledWith ( '/api/v1/mcptokens' , expect . objectContaining ( { projectName : 'labctl' } ) ) ;
const auth = JSON . parse ( readFileSync ( join ( tmpDir , 'auth.json' ) , 'utf-8' ) ) ;
expect ( auth [ 'mcp:labctl' ] ) . toEqual ( { type : 'api_key' , key : 'impersonated-tok' } ) ;
} ) ;
it ( 'uses --token without calling the API' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'docmost' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_custom' ] , { from : 'user' } ) ;
expect ( client . post ) . not . toHaveBeenCalled ( ) ;
const auth = JSON . parse ( readFileSync ( join ( tmpDir , 'auth.json' ) , 'utf-8' ) ) ;
expect ( auth [ 'mcp:docmost' ] ) . toEqual ( { type : 'api_key' , key : 'mcpctl_pat_custom' } ) ;
} ) ;
it ( 'keeps an existing credential and does not re-mint' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( join ( tmpDir , 'auth.json' ) , JSON . stringify ( { 'mcp:labctl' : { type : 'api_key' , key : 'existing' } } ) ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'labctl' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
expect ( client . post ) . not . toHaveBeenCalled ( ) ;
const auth = JSON . parse ( readFileSync ( join ( tmpDir , 'auth.json' ) , 'utf-8' ) ) ;
expect ( auth [ 'mcp:labctl' ] . key ) . toBe ( 'existing' ) ;
} ) ;
it ( 'installs the /mcpctl switcher extension by default, and skips with --skip-extension' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'ha' , '-o' , settingsPath , '--skip-skills' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
const extPath = join ( tmpDir , 'extensions' , 'mcpctl-switch.ts' ) ;
expect ( existsSync ( extPath ) ) . toBe ( true ) ;
expect ( readFileSync ( extPath , 'utf-8' ) ) . toContain ( "registerCommand('mcpctl'" ) ;
output . length = 0 ;
const cmd2 = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd2 . parseAsync ( [ 'prime-agent' , '--project' , 'ha' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
expect ( output . join ( '\n' ) ) . not . toContain ( 'switcher extension' ) ;
} ) ;
it ( 'does not write a .mcpctl-project marker when run from $HOME' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const prevCwd = process . cwd ( ) ;
process . chdir ( homedir ( ) ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
try {
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'proj-x' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
} finally {
process . chdir ( prevCwd ) ;
}
expect ( output . join ( '\n' ) ) . toContain ( 'Skipped .mcpctl-project marker' ) ;
expect ( exceptionSafeRead ( join ( homedir ( ) , '.mcpctl-project' ) ) ) . toBeNull ( ) ;
} ) ;
fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Addresses the second round of `config prime-agent` review (10 findings).
auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin:
- loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and-
rewrite, so one syntax error can no longer destroy the provider API key and
every other project's credential. hasPrimeAgentAuth shares that guarantee.
- writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead
of the default umask — bearer tokens are no longer world-readable on first
creation.
state ownership (commands/skills.ts) — the ownership model edge cases:
- orphan-removal guard now normalises a canonical scope (project name, or null
for globals; legacy undefined adopted to current scope) instead of comparing
null against undefined, so global-only syncs and pre-PR state can no longer
leave stale skills on disk forever.
- a same-named skill *tracked* to a different project is preserved (with a
warning) rather than silently overwritten in the shared flat tree.
- mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning
(prime-agent's HTTP gateway must not mutate shared mcpd project attachments);
this also makes the earlier dropped-attach concern explicit rather than silent.
single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts):
- registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes
other mcpctl-managed entries, so prime-agent has one *active* mcpctl project
while preserving untagged servers (hand-configured sre, websearch, etc).
- the /mcpctl extension now reads that tag as the single source of truth for the
active project, fixing the false short-circuit / no-op switch.
config.ts command:
- an explicit -p now updates a differing up-tree .mcpctl-project marker (scope
no longer silently reverts on the next sync), no-ops when it matches, and
still never scopes $HOME.
- a project left with no usable credential now exits non-zero (the /mcpctl
extension checks child exit status, so it no longer reports a successful
switch after provisioning failed).
- skills sync is treated as best-effort: settings+auth determine switch success,
so a skills error no longer falsely fails the switch.
- token minting now revokes prior active `prime-agent` tokens before creating a
fresh one (no more never-expiring token litter / lost-credential duplication).
Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit
code, single-active dedup preserving untagged sre, cross-project overwrite
preservation, and global-orphan removal on global-only sync.
2026-08-08 11:28:15 +01:00
it ( 'refuses to overwrite a corrupt auth.json (and does not mint over it)' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( join ( tmpDir , 'auth.json' ) , '{ not valid json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'x' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
expect ( output . join ( '\n' ) ) . toContain ( 'refusing to overwrite' ) ;
expect ( readFileSync ( join ( tmpDir , 'auth.json' ) , 'utf-8' ) ) . toBe ( '{ not valid json' ) ;
expect ( process . exitCode ) . toBe ( 1 ) ;
} ) ;
it ( 'exits non-zero when a credential cannot be provisioned' , async ( ) = > {
// mockClient post returns { token: ... } by default; override to no token.
const badClient = { . . . client , post : vi.fn ( async ( ) = > ( { } ) ) } as typeof client ;
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client : badClient , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'x' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
expect ( process . exitCode ) . toBe ( 1 ) ;
// body of provisioning error surfaced
expect ( output . join ( '\n' ) ) . toContain ( 'no token returned' ) ;
} ) ;
it ( 'writes auth.json with mode 0600' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'm' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ; // mint path, mock post returns token
const mode = statSync ( join ( tmpDir , 'auth.json' ) ) . mode & 0 o777 ;
expect ( mode ) . toBe ( 0 o600 ) ;
} ) ;
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
it ( 'refuses to overwrite a corrupt settings.json' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( settingsPath , '{ this is not valid json !!!' ) ;
const prevCwd = process . cwd ( ) ;
process . chdir ( tmpDir ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
try {
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'proj-9' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
} finally {
process . chdir ( prevCwd ) ;
}
expect ( output . join ( '\n' ) ) . toContain ( 'refusing to overwrite' ) ;
// The corrupt file is untouched.
expect ( readFileSync ( settingsPath , 'utf-8' ) ) . toBe ( '{ this is not valid json !!!' ) ;
} ) ;
fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Addresses the second round of `config prime-agent` review (10 findings).
auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin:
- loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and-
rewrite, so one syntax error can no longer destroy the provider API key and
every other project's credential. hasPrimeAgentAuth shares that guarantee.
- writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead
of the default umask — bearer tokens are no longer world-readable on first
creation.
state ownership (commands/skills.ts) — the ownership model edge cases:
- orphan-removal guard now normalises a canonical scope (project name, or null
for globals; legacy undefined adopted to current scope) instead of comparing
null against undefined, so global-only syncs and pre-PR state can no longer
leave stale skills on disk forever.
- a same-named skill *tracked* to a different project is preserved (with a
warning) rather than silently overwritten in the shared flat tree.
- mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning
(prime-agent's HTTP gateway must not mutate shared mcpd project attachments);
this also makes the earlier dropped-attach concern explicit rather than silent.
single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts):
- registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes
other mcpctl-managed entries, so prime-agent has one *active* mcpctl project
while preserving untagged servers (hand-configured sre, websearch, etc).
- the /mcpctl extension now reads that tag as the single source of truth for the
active project, fixing the false short-circuit / no-op switch.
config.ts command:
- an explicit -p now updates a differing up-tree .mcpctl-project marker (scope
no longer silently reverts on the next sync), no-ops when it matches, and
still never scopes $HOME.
- a project left with no usable credential now exits non-zero (the /mcpctl
extension checks child exit status, so it no longer reports a successful
switch after provisioning failed).
- skills sync is treated as best-effort: settings+auth determine switch success,
so a skills error no longer falsely fails the switch.
- token minting now revokes prior active `prime-agent` tokens before creating a
fresh one (no more never-expiring token litter / lost-credential duplication).
Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit
code, single-active dedup preserving untagged sre, cross-project overwrite
preservation, and global-orphan removal on global-only sync.
2026-08-08 11:28:15 +01:00
it ( 'keeps a single active mcpctl project, preserving untagged servers (sre)' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( settingsPath , JSON . stringify ( {
mcpServers : {
sre : { type : 'http' , url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/sre/mcp ` } , // untagged, hand-set
homeautomation : { type : 'http' , url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/homeautomation/mcp ` , mcpctlManaged : true } ,
} ,
} ) ) ;
// Active project is homeautomation (tagged). Switch to labctl.
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'labctl' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
const written = JSON . parse ( readFileSync ( settingsPath , 'utf-8' ) ) ;
expect ( written . mcpServers [ 'labctl' ] . mcpctlManaged ) . toBe ( true ) ; // new active
expect ( written . mcpServers [ 'homeautomation' ] ) . toBeUndefined ( ) ; // old managed removed
expect ( written . mcpServers [ 'sre' ] ) . toBeDefined ( ) ; // untagged preserved
} ) ;
fix(cli): close third review — token collision, migration, ownership
Round 2 fixed the first review but introduced regressions of its own, all
of which only bite against state written by the previously installed build.
`config prime-agent`:
- Mint each credential under a unique `prime-agent-<stamp>` name again.
`McpToken` is unique on (name, projectId) and revoke is a soft delete, so
round 2's fixed `prime-agent` name could only ever be minted once per
project — and the revoke-first ordering destroyed the working credential
before discovering the mint would fail.
- Provision the credential BEFORE touching settings.json. Registering the
new project unmounts the previously active one, so a failed mint must not
be able to leave prime-agent with no working project at all. The command
now aborts with settings.json untouched.
- Retire only the token this auth.json actually held, once its replacement
is stored. Sweeping every `prime-agent*` token for the project would
revoke the credential another install (or a custom --output run) is
using; anything else that looks orphaned is reported, not deleted.
- Validate a pre-existing credential instead of trusting its presence: a
revoked or expired token used to short-circuit provisioning and leave
prime-agent broken while the command reported success. Matched by
tokenPrefix against the project's active tokens, so the secret is never
sent. Fails open when the API can't be consulted.
- Actually write auth.json 0600. `writeFile`'s mode is ignored for an
existing file and prime-agent creates auth.json itself at 0644, so chmod
after writing.
- Recognise the untagged mcpServers entries older CLIs wrote (canonical
proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts
them instead of leaving two gateways live. Hand-configured servers have
no such credential and are still preserved. Same rule in the `/mcpctl`
switcher's active-project lookup.
- Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the
extension runs from whatever directory prime-agent was started in, and
was silently re-scoping that repo's `.mcpctl-project`.
`skills sync --agent prime-agent`:
- Record ownership from the skill's own scope, not the syncing project's.
Globals were being pinned to whichever project happened to sync them,
after which every other project refused to update them forever.
- Never adopt legacy, ownership-less state into the current scope. Round 2
did, which deleted the other project's skills on the first sync after
upgrading. Such entries are attributed to the project that last wrote the
state file, and left alone when that isn't the project syncing now.
- Close the overwrite-guard bypass: a sync with no project, or a global
landing on a project-owned name, could still clobber and re-own a
tracked skill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
it ( 'adopts an untagged entry an older CLI wrote, keeping hand-configured ones' , async ( ) = > {
// Written by a CLI that predates `mcpctlManaged`: an untagged entry whose
// URL is canonical AND a matching mcp:<project> PAT in auth.json.
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( settingsPath , JSON . stringify ( {
mcpServers : {
legacy : { type : 'http' , url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/legacy/mcp ` } ,
websearch : { type : 'http' , url : 'https://search.example/mcp' } , // hand-configured
sre : { type : 'http' , url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/sre/mcp ` } , // canonical URL, no credential
} ,
} ) ) ;
writeFileSync ( join ( tmpDir , 'auth.json' ) , JSON . stringify ( {
itaz : { type : 'api_key' , key : 'sk-provider' } ,
'mcp:legacy' : { type : 'api_key' , key : 'mcpctl_pat_legacytoken1234' } ,
} ) ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'labctl' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
const written = JSON . parse ( readFileSync ( settingsPath , 'utf-8' ) ) ;
expect ( written . mcpServers [ 'legacy' ] ) . toBeUndefined ( ) ; // adopted + unmounted
expect ( written . mcpServers [ 'websearch' ] ) . toBeDefined ( ) ; // unrelated, preserved
expect ( written . mcpServers [ 'sre' ] ) . toBeDefined ( ) ; // no PAT → hand-set, preserved
expect ( written . mcpServers [ 'labctl' ] . mcpctlManaged ) . toBe ( true ) ;
} ) ;
it ( 'mints each credential under a unique name (never a fixed one)' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'p' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
const body = client . post . mock . calls . find ( ( c ) = > c [ 0 ] === '/api/v1/mcptokens' ) ? . [ 1 ] as { name : string } ;
// A fixed name can only ever be minted once: McpToken is unique on
// (name, projectId) and revoke is a soft delete.
expect ( body . name ) . not . toBe ( 'prime-agent' ) ;
expect ( body . name ) . toMatch ( /^prime-agent-[a-z0-9-]+$/ ) ;
} ) ;
it ( 'revokes the token it replaced, only after the replacement is stored' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const authPath = join ( tmpDir , 'auth.json' ) ;
writeFileSync ( authPath , JSON . stringify ( {
'mcp:p' : { type : 'api_key' , key : 'mcpctl_pat_oldtoken00000' } ,
} ) ) ;
const order : string [ ] = [ ] ;
const api = {
get : vi . fn ( async ( url : string ) = > {
order . push ( ` get ${ url } ` ) ;
return [
{ id : 'tok-old' , name : 'prime-agent-abc' , status : 'active' , tokenPrefix : 'mcpctl_pat_oldto' } ,
{ id : 'tok-other' , name : 'ci-runner' , status : 'active' , tokenPrefix : 'mcpctl_pat_ci000' } ,
] ;
} ) ,
post : vi.fn ( async ( url : string ) = > {
order . push ( ` post ${ url } ` ) ;
return { } ;
} ) ,
put : vi.fn ( async ( ) = > ( { } ) ) ,
delete : vi . fn ( async ( ) = > { } ) ,
} as unknown as ApiClient ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client : api , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
// Explicitly replace the stored credential.
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'p' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_supplied00000' ] , { from : 'user' } ) ;
// The new credential landed on disk...
expect ( JSON . parse ( readFileSync ( authPath , 'utf-8' ) ) [ 'mcp:p' ] . key ) . toBe ( 'mcpctl_pat_supplied00000' ) ;
// ...before the token it replaced was revoked — never the other way round.
const revokeAt = order . indexOf ( 'post /api/v1/mcptokens/tok-old/revoke' ) ;
expect ( revokeAt ) . toBeGreaterThanOrEqual ( 0 ) ;
expect ( statSync ( authPath ) . mtimeMs ) . toBeGreaterThan ( 0 ) ;
// Tokens this auth.json never held are reported, never revoked.
expect ( order ) . not . toContain ( 'post /api/v1/mcptokens/tok-other/revoke' ) ;
} ) ;
it ( 'never revokes a token this auth.json did not hold' , async ( ) = > {
// A run against a custom --output (or a second machine) must not touch the
// credential the real install is using.
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const api = {
get : vi . fn ( async ( ) = > [
{ id : 'tok-elsewhere' , name : 'prime-agent-abc' , status : 'active' , tokenPrefix : 'mcpctl_pat_elsew' } ,
] ) ,
post : vi.fn ( async ( url : string ) = > ( url === '/api/v1/mcptokens' ? { token : 'mcpctl_pat_brandnew0000' } : { } ) ) ,
put : vi.fn ( async ( ) = > ( { } ) ) ,
delete : vi . fn ( async ( ) = > { } ) ,
} as unknown as ApiClient ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client : api , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'p' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
const revokes = api . post . mock . calls . filter ( ( c ) = > String ( c [ 0 ] ) . includes ( '/revoke' ) ) ;
expect ( revokes ) . toEqual ( [ ] ) ;
// ...but the user is told about it rather than left guessing.
expect ( output . join ( '\n' ) ) . toContain ( 'prime-agent-abc' ) ;
} ) ;
it ( 'leaves settings.json untouched when the credential cannot be provisioned' , async ( ) = > {
// The active project must keep working when a switch fails: registering the
// new project unmounts the old one, so it may not run before the mint.
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const before = JSON . stringify ( {
mcpServers : {
homeautomation : { type : 'http' , url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/homeautomation/mcp ` , mcpctlManaged : true } ,
} ,
} ) ;
writeFileSync ( settingsPath , before ) ;
const badClient = { . . . client , get : vi . fn ( async ( ) = > [ ] ) , post : vi.fn ( async ( ) = > ( { } ) ) } as unknown as ApiClient ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client : badClient , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'labctl' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
expect ( process . exitCode ) . toBe ( 1 ) ;
expect ( readFileSync ( settingsPath , 'utf-8' ) ) . toBe ( before ) ;
} ) ;
it ( 're-mints when the stored credential is no longer active' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( join ( tmpDir , 'auth.json' ) , JSON . stringify ( {
'mcp:p' : { type : 'api_key' , key : 'mcpctl_pat_revoked000000' } ,
} ) ) ;
const api = {
get : vi . fn ( async ( ) = > [
{ id : 'tok-1' , name : 'prime-agent-old' , status : 'revoked' , tokenPrefix : 'mcpctl_pat_revo' } ,
] ) ,
post : vi.fn ( async ( ) = > ( { token : 'mcpctl_pat_fresh0000000' } ) ) ,
put : vi.fn ( async ( ) = > ( { } ) ) ,
delete : vi . fn ( async ( ) = > { } ) ,
} as unknown as ApiClient ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client : api , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'p' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
const auth = JSON . parse ( readFileSync ( join ( tmpDir , 'auth.json' ) , 'utf-8' ) ) ;
expect ( auth [ 'mcp:p' ] . key ) . toBe ( 'mcpctl_pat_fresh0000000' ) ;
expect ( process . exitCode ) . toBe ( 0 ) ;
} ) ;
it ( 'keeps a stored credential that is still active' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( join ( tmpDir , 'auth.json' ) , JSON . stringify ( {
'mcp:p' : { type : 'api_key' , key : 'mcpctl_pat_liveaaaaaaaa' } ,
} ) ) ;
const api = {
get : vi . fn ( async ( ) = > [
// mcpd records the first 16 chars of the raw token as tokenPrefix.
{ id : 'tok-1' , name : 'prime-agent-x' , status : 'active' , tokenPrefix : 'mcpctl_pat_livea' } ,
] ) ,
post : vi.fn ( async ( ) = > ( { token : 'should-not-be-minted' } ) ) ,
put : vi.fn ( async ( ) = > ( { } ) ) ,
delete : vi . fn ( async ( ) = > { } ) ,
} as unknown as ApiClient ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client : api , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'p' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
expect ( api . post ) . not . toHaveBeenCalled ( ) ;
const auth = JSON . parse ( readFileSync ( join ( tmpDir , 'auth.json' ) , 'utf-8' ) ) ;
expect ( auth [ 'mcp:p' ] . key ) . toBe ( 'mcpctl_pat_liveaaaaaaaa' ) ;
} ) ;
it ( 'tightens a pre-existing 0644 auth.json to 0600' , async ( ) = > {
// prime-agent creates auth.json itself with the default umask; writeFile's
// `mode` is ignored for an existing file, so the write must chmod.
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const authPath = join ( tmpDir , 'auth.json' ) ;
writeFileSync ( authPath , JSON . stringify ( { itaz : { type : 'api_key' , key : 'sk-x' } } ) , { mode : 0o644 } ) ;
chmodSync ( authPath , 0 o644 ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'm' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' ] , { from : 'user' } ) ;
expect ( statSync ( authPath ) . mode & 0 o777 ) . toBe ( 0 o600 ) ;
// The provider credential is still there.
expect ( JSON . parse ( readFileSync ( authPath , 'utf-8' ) ) . itaz . key ) . toBe ( 'sk-x' ) ;
} ) ;
it ( '--skip-marker leaves the current directory alone' , async ( ) = > {
// The /mcpctl switcher runs from whatever directory prime-agent started in.
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'sre' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--skip-marker' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
expect ( exceptionSafeRead ( join ( tmpDir , '.mcpctl-project' ) ) ) . toBeNull ( ) ;
} ) ;
2026-08-08 12:18:21 +01:00
it ( 'the installed switcher extension publishes the active project to the footer' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'ha' , '-o' , settingsPath , '--skip-skills' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
const ext = readFileSync ( join ( tmpDir , 'extensions' , 'mcpctl-switch.ts' ) , 'utf-8' ) ;
// Footer status, refreshed on startup and on every reload (which is what
// the switch itself triggers) — the mcpctl equivalent of the model name.
expect ( ext ) . toContain ( "pi.on('session_start'" ) ;
expect ( ext ) . toContain ( 'ctx.ui.setStatus(STATUS_KEY' ) ;
expect ( ext ) . toContain ( '`mcpctl:${active}`' ) ;
// notify() only accepts info|warning|error — 'success' is not a valid type.
expect ( ext ) . not . toContain ( "'success'" ) ;
} ) ;
fix(cli): close third review — token collision, migration, ownership
Round 2 fixed the first review but introduced regressions of its own, all
of which only bite against state written by the previously installed build.
`config prime-agent`:
- Mint each credential under a unique `prime-agent-<stamp>` name again.
`McpToken` is unique on (name, projectId) and revoke is a soft delete, so
round 2's fixed `prime-agent` name could only ever be minted once per
project — and the revoke-first ordering destroyed the working credential
before discovering the mint would fail.
- Provision the credential BEFORE touching settings.json. Registering the
new project unmounts the previously active one, so a failed mint must not
be able to leave prime-agent with no working project at all. The command
now aborts with settings.json untouched.
- Retire only the token this auth.json actually held, once its replacement
is stored. Sweeping every `prime-agent*` token for the project would
revoke the credential another install (or a custom --output run) is
using; anything else that looks orphaned is reported, not deleted.
- Validate a pre-existing credential instead of trusting its presence: a
revoked or expired token used to short-circuit provisioning and leave
prime-agent broken while the command reported success. Matched by
tokenPrefix against the project's active tokens, so the secret is never
sent. Fails open when the API can't be consulted.
- Actually write auth.json 0600. `writeFile`'s mode is ignored for an
existing file and prime-agent creates auth.json itself at 0644, so chmod
after writing.
- Recognise the untagged mcpServers entries older CLIs wrote (canonical
proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts
them instead of leaving two gateways live. Hand-configured servers have
no such credential and are still preserved. Same rule in the `/mcpctl`
switcher's active-project lookup.
- Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the
extension runs from whatever directory prime-agent was started in, and
was silently re-scoping that repo's `.mcpctl-project`.
`skills sync --agent prime-agent`:
- Record ownership from the skill's own scope, not the syncing project's.
Globals were being pinned to whichever project happened to sync them,
after which every other project refused to update them forever.
- Never adopt legacy, ownership-less state into the current scope. Round 2
did, which deleted the other project's skills on the first sync after
upgrading. Such entries are attributed to the project that last wrote the
state file, and left alone when that isn't the project syncing now.
- Close the overwrite-guard bypass: a sync with no project, or a global
landing on a project-owned name, could still clobber and re-own a
tracked skill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 12:01:52 +01:00
it ( 'the installed switcher extension passes --skip-marker' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'ha' , '-o' , settingsPath , '--skip-skills' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
const ext = readFileSync ( join ( tmpDir , 'extensions' , 'mcpctl-switch.ts' ) , 'utf-8' ) ;
expect ( ext ) . toContain ( "'--skip-extension', '--skip-marker'" ) ;
} ) ;
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
it ( 'merges a re-configured project entry, preserving user-added fields' , async ( ) = > {
const settingsPath = join ( tmpDir , 'settings.json' ) ;
writeFileSync ( settingsPath , JSON . stringify ( {
mcpServers : {
ha : { type : 'http' , url : 'https://old/projects/ha/mcp' , headers : { Authorization : 'Bearer u' } } ,
} ,
} ) ) ;
const cmd = createConfigCommand (
{ configDeps : { configDir : tmpDir } , log } ,
{ client , credentialsDeps : { configDir : tmpDir } , log } ,
) ;
await cmd . parseAsync ( [ 'prime-agent' , '--project' , 'ha' , '-o' , settingsPath , '--skip-skills' , '--skip-extension' , '--token' , 'mcpctl_pat_x' ] , { from : 'user' } ) ;
const written = JSON . parse ( readFileSync ( settingsPath , 'utf-8' ) ) ;
expect ( written . mcpServers [ 'ha' ] ) . toEqual ( {
type : 'http' ,
url : ` ${ DEFAULT_MCPCTL_GATEWAY_URL } /projects/ha/mcp ` ,
headers : { Authorization : 'Bearer u' } , // user-added field preserved
fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Addresses the second round of `config prime-agent` review (10 findings).
auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin:
- loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and-
rewrite, so one syntax error can no longer destroy the provider API key and
every other project's credential. hasPrimeAgentAuth shares that guarantee.
- writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead
of the default umask — bearer tokens are no longer world-readable on first
creation.
state ownership (commands/skills.ts) — the ownership model edge cases:
- orphan-removal guard now normalises a canonical scope (project name, or null
for globals; legacy undefined adopted to current scope) instead of comparing
null against undefined, so global-only syncs and pre-PR state can no longer
leave stale skills on disk forever.
- a same-named skill *tracked* to a different project is preserved (with a
warning) rather than silently overwritten in the shared flat tree.
- mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning
(prime-agent's HTTP gateway must not mutate shared mcpd project attachments);
this also makes the earlier dropped-attach concern explicit rather than silent.
single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts):
- registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes
other mcpctl-managed entries, so prime-agent has one *active* mcpctl project
while preserving untagged servers (hand-configured sre, websearch, etc).
- the /mcpctl extension now reads that tag as the single source of truth for the
active project, fixing the false short-circuit / no-op switch.
config.ts command:
- an explicit -p now updates a differing up-tree .mcpctl-project marker (scope
no longer silently reverts on the next sync), no-ops when it matches, and
still never scopes $HOME.
- a project left with no usable credential now exits non-zero (the /mcpctl
extension checks child exit status, so it no longer reports a successful
switch after provisioning failed).
- skills sync is treated as best-effort: settings+auth determine switch success,
so a skills error no longer falsely fails the switch.
- token minting now revokes prior active `prime-agent` tokens before creating a
fresh one (no more never-expiring token litter / lost-credential duplication).
Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit
code, single-active dedup preserving untagged sre, cross-project overwrite
preservation, and global-orphan removal on global-only sync.
2026-08-08 11:28:15 +01:00
mcpctlManaged : true ,
fix(cli): harden `config prime-agent` sync + install /mcpctl switcher extension
Addresses a review of the `config prime-agent` feature and adds the in-app
project switcher.
Safety/correctness fixes (prime-agent's shared, hand-editable ~/.prime/agent
tree must never suffer silent data loss):
- config/prime-agent.ts: loadPrimeAgentSettings now fails loudly on corrupt
JSON instead of swallowing it and rewriting the file (which destroyed every
non-mcpServers setting). A project's mcpServers entry is merged (keeping
user-added fields) rather than replaced wholesale. Added writePrimeAgentAuth
/ hasPrimeAgentAuth helpers for auth provisioning.
- skills sync: unified the near-verbatim prime-agent copy into runSkillsSync
via a `target: 'claude' | 'prime-agent'` option (prime-agent-skills.ts is now
a thin wrapper). Under the prime-agent target it: preserves untracked
pre-existing skill dirs on first sync (no more rm -rf of hand-authored `sre`),
records per-project ownership so configuring a second project never deletes
the first project's skills, skips Claude-only hooks/postInstall, and keeps
the mcpServers auto-attach step.
- config.ts: `config prime-agent` now (a) provisions the bearer credential in
auth.json (--token, existing entry, or auto-mint via POST /api/v1/mcptokens),
(b) writes the .mcpctl-project marker only when none exists up-tree and never
from $HOME, and (c) propagates the skills sync exit code so auth failures are
reported instead of swallowing them.
- skills.ts: `--agent` is validated; an unknown value errors instead of
silently running the Claude sync.
New feature: `config prime-agent` installs a `/mcpctl` project-switcher
extension into ~/.prime/agent/extensions/ (skip with --skip-extension). It lists
mcpctl projects via `mcpctl get projects -o json`, lets you pick one from the
prime-agent TUI, applies the switch through the CLI, and reloads the session.
Regenerated shell completions. Tests: 538 pass (new coverage for settings
corruption, entry merge, auth provisioning, extension install/skip, marker
$HOME handling, untracked/cross-project skill preservation, --agent validation).
2026-08-08 10:22:46 +01:00
} ) ;
} ) ;
2026-08-08 09:34:15 +01:00
} ) ;
function exceptionSafeRead ( path : string ) : string | null {
try {
return readFileSync ( path , 'utf-8' ) ;
} catch {
return null ;
}
}