Files
mcpctl/src/cli/tests/utils/sessionhook.test.ts

152 lines
6.5 KiB
TypeScript
Raw Normal View History

feat(cli+mcpd): mcpctl skills sync + config claude extension Phase 5 of the Skills + Revisions + Proposals work. Skills are now materialised onto disk under ~/.claude/skills/<name>/, with hash-pinned diff against mcpd, atomic per-skill install, and preservation of locally-modified files. `mcpctl config claude --project X` now wires the full pickup chain: writes .mcpctl-project marker, runs the initial sync, installs the SessionStart hook so subsequent Claude invocations stay in sync transparently. ## Sync algorithm 1. Resolve project: `--project` flag overrides; else walk up from cwd looking for `.mcpctl-project`; else fall back to globals-only. 2. GET /api/v1/projects/:name/skills/visible (or /api/v1/skills?scope=global without a project). Server returns id + name + semver + scope + contentHash + metadata — no body, no files. The contentHash is sha256 of the canonicalised body, computed server-side; any reordering of keys produces the same hash, so it's a stable diff key. 3. Load ~/.mcpctl/skills-state.json (lives outside ~/.claude/skills/ on purpose — Claude Code reads that tree and we don't want to pollute it with our bookkeeping). 4. Diff: - server skill not in state → INSTALL - server skill, state contentHash matches → SKIP (cheap path) - server skill, state contentHash differs → UPDATE (fetch full body) - state skill not in server → orphan, REMOVE (preserve if locally modified, unless --force) 5. Atomic per-skill install: write to <targetDir>.mcpctl-staging-<pid>/, rename existing tree to .mcpctl-trash-<pid>, swap staging in, rmtree the trash. A concurrent reader (Claude Code starting up) never sees a partial tree. 6. State file updated with new versions, per-file SHA-256, install path. saveState is atomic (temp + rename). ## Failure semantics - `--quiet` mode (used by SessionStart hook): exit 0 on network / timeout / mcpd error. Fail-open is non-negotiable here — we never want a hung mcpd to block Claude Code starting up. - Auth failure: exit 1, clear "run mcpctl login" message. - Disk error during state save: exit 2. - Per-skill errors are collected in the result and reported as a count; one bad skill doesn't stop the others. Network fetches run with concurrency 5. The server-side `/visible` endpoint is metadata-only so the cheap path (everything unchanged) needs exactly one HTTP roundtrip total. ## Files added ### CLI utilities (src/cli/src/utils/) - skills-state.ts — load/save state, per-file sha256, edit detection. - project-marker.ts — walk-up to find `.mcpctl-project`, bounded by user home so we never search above $HOME. - sessionhook.ts — install/remove a SessionStart hook entry tagged with `_mcpctl_managed: true`. Idempotent. Defensive against missing/empty/JSONC settings.json. - skills-disk.ts — atomic install via staging-dir rename swap, symmetric atomic delete via trash-dir rename. Path-escape attempts in files{} are rejected. ### CLI command (src/cli/src/commands/) - skills.ts — `mcpctl skills sync` Commander wrapper + the `runSkillsSync(opts, deps)` library function (also called from `mcpctl config claude --project`). Supports `--dry-run`, `--force`, `--quiet`, `--keep-orphans`. `--skip-postinstall` is reserved (postInstall execution lands in a follow-up PR, not this one). ### Wiring - index.ts: registers `mcpctl skills` after `mcpctl review`. - config.ts: `mcpctl config claude --project X` now writes the `.mcpctl-project` marker, runs `runSkillsSync` in-process, and calls `installManagedSessionHook('mcpctl skills sync --quiet')`. New flag `--skip-skills` opts out (used by tests; useful for CI). ## Server-side change - src/mcpd/src/services/skill.service.ts: getVisibleSkills now computes contentHash on the fly from the canonical body shape the client will reconstruct. Cheap (sha256 of ~few KB per skill); no schema migration needed since hash is derived not stored. ## Tests Four new utility test files (31 tests) under src/cli/tests/utils/: - sessionhook.test.ts — creation, idempotency, command updates, preservation of user hooks, removal, empty/JSONC tolerance. - skills-disk.test.ts — atomic write, replacement without leftovers, path-escape rejection, atomic delete, listing ignores staging/trash artifacts. - skills-state.test.ts — sha256 determinism, state round-trip, schema-version drift handling, edit detection. - project-marker.test.ts — cwd hit, walk-up, $HOME boundary, empty marker, write+read round-trip. The existing `mcpctl config claude` test (claude.test.ts) was updated to pass `--skip-skills` so it stays focused on .mcp.json generation; the new sync flow is covered by the utility tests. Full suite: 162 test files / 2157 tests green (up from 158 / 2127). ## Deferred to a follow-up - `metadata.hooks` materialisation into `~/.claude/settings.json` — the data path exists, sync receives it; PR-7 or a focused follow-up will write the `_mcpctl_managed: true` entries for declarative hooks. - `metadata.mcpServers` auto-attach via mcpd API — likewise. - `metadata.postInstall` script execution — the most substantive deferred piece. Current sync logs a TODO and skips. The corporate trust model (publisher-side rigor, not client-side defence) means this is straightforward to add once we wire the curated env + timeout + audit emission. Orthogonal to file sync, easier to ship separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:26:35 +01:00
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installManagedSessionHook, removeManagedSessionHook, MARKER_KEY } from '../../src/utils/sessionhook.js';
describe('sessionhook', () => {
let tmp: string;
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'mcpctl-sessionhook-'));
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
it('creates settings.json from scratch when missing', async () => {
const path = join(tmp, 'settings.json');
const result = await installManagedSessionHook('mcpctl skills sync --quiet', path);
expect(result.updated).toBe(true);
const settings = JSON.parse(await readFile(path, 'utf-8'));
expect(settings.hooks.SessionStart).toHaveLength(1);
const entry = settings.hooks.SessionStart[0].hooks[0];
expect(entry.command).toBe('mcpctl skills sync --quiet');
expect(entry[MARKER_KEY]).toBe(true);
});
it('is idempotent — re-running does not add duplicates', async () => {
const path = join(tmp, 'settings.json');
await installManagedSessionHook('mcpctl skills sync --quiet', path);
const second = await installManagedSessionHook('mcpctl skills sync --quiet', path);
expect(second.updated).toBe(false);
const settings = JSON.parse(await readFile(path, 'utf-8'));
const entries = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks);
const managed = entries.filter((e: Record<string, unknown>) => e[MARKER_KEY] === true);
expect(managed).toHaveLength(1);
});
it('updates the command in place when it changes', async () => {
const path = join(tmp, 'settings.json');
await installManagedSessionHook('mcpctl skills sync', path);
const updated = await installManagedSessionHook('mcpctl skills sync --quiet', path);
expect(updated.updated).toBe(true);
const settings = JSON.parse(await readFile(path, 'utf-8'));
const managed = settings.hooks.SessionStart
.flatMap((g: { hooks: unknown[] }) => g.hooks)
.find((e: Record<string, unknown>) => e[MARKER_KEY] === true);
expect(managed.command).toBe('mcpctl skills sync --quiet');
});
it('preserves non-managed hooks', async () => {
const path = join(tmp, 'settings.json');
await mkdir(tmp, { recursive: true });
await writeFile(path, JSON.stringify({
hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-hook' }] }],
},
}));
await installManagedSessionHook('mcpctl skills sync --quiet', path);
const settings = JSON.parse(await readFile(path, 'utf-8'));
const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks);
expect(all).toHaveLength(2);
expect(all.find((e: Record<string, unknown>) => e.command === 'echo user-hook')).toBeDefined();
expect(all.find((e: Record<string, unknown>) => e[MARKER_KEY] === true)).toBeDefined();
});
it('remove drops the managed entry but keeps user hooks', async () => {
const path = join(tmp, 'settings.json');
await writeFile(path, JSON.stringify({
hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'echo user' }] }],
},
}));
await installManagedSessionHook('mcpctl skills sync --quiet', path);
const removed = await removeManagedSessionHook(path);
expect(removed.removed).toBe(true);
const settings = JSON.parse(await readFile(path, 'utf-8'));
const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks);
expect(all).toHaveLength(1);
expect(all[0].command).toBe('echo user');
});
it('remove is a no-op when no managed entry exists', async () => {
const path = join(tmp, 'settings.json');
const result = await removeManagedSessionHook(path);
expect(result.removed).toBe(false);
});
it('survives empty settings.json', async () => {
const path = join(tmp, 'settings.json');
await writeFile(path, '');
await installManagedSessionHook('mcpctl skills sync --quiet', path);
const settings = JSON.parse(await readFile(path, 'utf-8'));
expect(settings.hooks.SessionStart).toHaveLength(1);
});
it('strips line comments before parsing', async () => {
const path = join(tmp, 'settings.json');
await writeFile(path, '// a leading comment\n{\n "hooks": {}\n}\n');
await installManagedSessionHook('mcpctl skills sync --quiet', path);
const settings = JSON.parse(await readFile(path, 'utf-8'));
expect(settings.hooks.SessionStart).toHaveLength(1);
});
});
refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs 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
2026-08-09 19:20:36 +01:00
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');
});
});