Integrate skills + v7-visibility + mcpctl passwd (deployed) #75
@@ -21,6 +21,11 @@ import {
|
||||
emitPostInstallAudit,
|
||||
hashScript,
|
||||
} from '../utils/postinstall.js';
|
||||
import {
|
||||
applyManagedHooks,
|
||||
removeManagedHooks,
|
||||
type HooksByEvent,
|
||||
} from '../utils/hooks-materialiser.js';
|
||||
import { ApiError } from '../api-client.js';
|
||||
|
||||
/**
|
||||
@@ -64,8 +69,9 @@ interface FullSkill {
|
||||
interface SyncedSkillMetadata {
|
||||
postInstall?: unknown;
|
||||
postInstallTimeoutSec?: unknown;
|
||||
// hooks / mcpServers materialisation lives in a follow-up — see TODO
|
||||
// in applyOne where we'd call them after the file install.
|
||||
hooks?: unknown;
|
||||
// mcpServers auto-attach lives in a follow-up — straightforward
|
||||
// wrapper around the existing `mcpctl create serverattachment` path.
|
||||
}
|
||||
|
||||
export interface SyncOpts {
|
||||
@@ -92,6 +98,7 @@ export interface SyncResult {
|
||||
preserved: string[]; // skills with local edits we left alone
|
||||
postInstallsRan: string[]; // skills whose postInstall executed in this sync
|
||||
postInstallsSkipped: string[]; // skills with postInstall but unchanged hash → no rerun
|
||||
hooksApplied: string[]; // skills whose hooks were registered/updated in ~/.claude/settings.json
|
||||
errors: Array<{ skill: string; error: string }>;
|
||||
exitCode: 0 | 1 | 2;
|
||||
}
|
||||
@@ -117,6 +124,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
preserved: [],
|
||||
postInstallsRan: [],
|
||||
postInstallsSkipped: [],
|
||||
hooksApplied: [],
|
||||
errors: [],
|
||||
exitCode: 0,
|
||||
};
|
||||
@@ -211,6 +219,8 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
continue;
|
||||
}
|
||||
await removeSkillAtomic(prior.installDir);
|
||||
// Drop any hook entries this skill registered.
|
||||
try { await removeManagedHooks(name); } catch { /* best-effort */ }
|
||||
delete state.skills[name];
|
||||
result.removed.push(name);
|
||||
} catch (err: unknown) {
|
||||
@@ -278,12 +288,29 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
};
|
||||
const fileStates = await installSkillAtomic(targetDir, body);
|
||||
|
||||
// ── hooks: register metadata.hooks in ~/.claude/settings.json ──
|
||||
// Tagged with _mcpctl_source: <skill-name> so each skill's hooks
|
||||
// can be cleanly added/updated/removed without trampling other
|
||||
// skills or user-added hooks. No-op when the field is absent or
|
||||
// empty.
|
||||
const meta = (full.metadata ?? {}) as SyncedSkillMetadata;
|
||||
if (meta.hooks && typeof meta.hooks === 'object') {
|
||||
try {
|
||||
const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent);
|
||||
if (hookRes.updated) result.hooksApplied.push(v.name);
|
||||
} catch (err: unknown) {
|
||||
warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
} else if (prior !== undefined) {
|
||||
// Skill no longer declares hooks but used to — clean up.
|
||||
try { await removeManagedHooks(v.name); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// ── postInstall: run metadata.postInstall when present ──
|
||||
// Hash-pinned: only execute when the script's sha256 differs from
|
||||
// what state recorded. Failures DO NOT update the recorded hash so
|
||||
// the next sync retries. Other skills continue regardless.
|
||||
let postInstallHash: string | null = prior?.postInstallHash ?? null;
|
||||
const meta = (full.metadata ?? {}) as SyncedSkillMetadata;
|
||||
if (
|
||||
!opts.skipPostInstall &&
|
||||
typeof meta.postInstall === 'string' &&
|
||||
|
||||
180
src/cli/src/utils/hooks-materialiser.ts
Normal file
180
src/cli/src/utils/hooks-materialiser.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Materialise skill-declared hooks into Claude Code's
|
||||
* `~/.claude/settings.json`.
|
||||
*
|
||||
* Each entry we write carries two markers:
|
||||
* `_mcpctl_managed: true` — same flag the SessionStart-hook
|
||||
* installer uses; identifies an entry mcpctl owns.
|
||||
* `_mcpctl_source: "<skill-name>"` — which skill installed it.
|
||||
*
|
||||
* The combination lets us cleanly add/update/remove skill hooks without
|
||||
* clobbering hooks the user added by hand and without one skill trampling
|
||||
* another. Removing skill X re-reads the file, drops every entry tagged
|
||||
* `_mcpctl_source: "X"`, and rewrites atomically.
|
||||
*
|
||||
* Claude Code ignores the extra fields (it only looks at `type` and
|
||||
* `command`).
|
||||
*
|
||||
* The file is written atomically (temp + rename) and tolerant of an
|
||||
* existing file that has comments, no `hooks` block, or unexpected
|
||||
* shape — same robustness profile as sessionhook.ts.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import { MARKER_KEY } from './sessionhook.js';
|
||||
|
||||
export const SOURCE_KEY = '_mcpctl_source';
|
||||
|
||||
/** A single hook entry: must be `type: 'command'` for v1. Extra fields preserved. */
|
||||
export interface ManagedHookEntry {
|
||||
type: 'command';
|
||||
command: string;
|
||||
timeout?: number;
|
||||
/** Free-form: skills can attach extra fields and they'll round-trip. */
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Recognised hook events. Validated server-side; if a new event lands later, we still write whatever the skill declares. */
|
||||
export type HookEvent =
|
||||
| 'PreToolUse'
|
||||
| 'PostToolUse'
|
||||
| 'SessionStart'
|
||||
| 'Stop'
|
||||
| 'SubagentStop'
|
||||
| 'Notification';
|
||||
|
||||
export type HooksByEvent = Partial<Record<HookEvent, ManagedHookEntry[]>>;
|
||||
|
||||
interface HookGroup {
|
||||
hooks: ManagedHookEntry[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
hooks?: Partial<Record<string, HookGroup[]>>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function defaultSettingsPath(): string {
|
||||
return join(homedir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
async function readSettings(path: string): Promise<Settings> {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8');
|
||||
if (raw.trim().length === 0) return {};
|
||||
const stripped = raw.replace(/^\s*\/\/.*$/gm, '');
|
||||
return JSON.parse(stripped) as Settings;
|
||||
} catch (err: unknown) {
|
||||
if (isNotFoundError(err)) return {};
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSettings(path: string, settings: Settings): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp.${String(process.pid)}`;
|
||||
await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
function isManagedBy(entry: unknown, source: string): boolean {
|
||||
if (entry === null || typeof entry !== 'object') return false;
|
||||
const e = entry as Record<string, unknown>;
|
||||
return e[MARKER_KEY] === true && e[SOURCE_KEY] === source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this skill's hook entries with the provided set. If `hooks`
|
||||
* omits an event the skill previously installed, those entries are
|
||||
* dropped. Other skills' entries and user-added entries are preserved.
|
||||
*
|
||||
* Returns the count of changes (added or removed entries) so callers
|
||||
* can short-circuit no-op writes.
|
||||
*/
|
||||
export async function applyManagedHooks(
|
||||
source: string,
|
||||
hooks: HooksByEvent,
|
||||
settingsPath: string = defaultSettingsPath(),
|
||||
): Promise<{ updated: boolean; settingsPath: string }> {
|
||||
const settings = await readSettings(settingsPath);
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
|
||||
let changed = false;
|
||||
|
||||
// For each known/declared event, drop our previous entries and add the new ones.
|
||||
const declaredEvents = new Set<string>(Object.keys(hooks));
|
||||
// Also walk events that already have entries from this source (so skills can shrink scope).
|
||||
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
||||
if (!Array.isArray(groups)) continue;
|
||||
if (groups.some((g) => Array.isArray(g.hooks) && g.hooks.some((e) => isManagedBy(e, source)))) {
|
||||
declaredEvents.add(eventName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of declaredEvents) {
|
||||
const desired = hooks[eventName as HookEvent] ?? [];
|
||||
const groups = (settings.hooks[eventName] as HookGroup[] | undefined) ?? [];
|
||||
|
||||
// Strip our entries from each group, then drop empty groups.
|
||||
const stripped: HookGroup[] = [];
|
||||
for (const group of groups) {
|
||||
if (!Array.isArray(group?.hooks)) {
|
||||
stripped.push(group);
|
||||
continue;
|
||||
}
|
||||
const before = group.hooks.length;
|
||||
const filtered = group.hooks.filter((e) => !isManagedBy(e, source));
|
||||
if (filtered.length !== before) changed = true;
|
||||
if (filtered.length > 0) {
|
||||
stripped.push({ ...group, hooks: filtered });
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the new set as a single group tagged with our source.
|
||||
if (desired.length > 0) {
|
||||
const tagged = desired.map((entry) => ({
|
||||
...entry,
|
||||
type: 'command' as const,
|
||||
[MARKER_KEY]: true,
|
||||
[SOURCE_KEY]: source,
|
||||
}));
|
||||
stripped.push({ hooks: tagged, [SOURCE_KEY]: source });
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (stripped.length === 0) {
|
||||
// No groups left for this event — drop the event entirely so the
|
||||
// settings.json doesn't accumulate empty arrays.
|
||||
delete settings.hooks[eventName];
|
||||
} else {
|
||||
settings.hooks[eventName] = stripped;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { updated: false, settingsPath };
|
||||
}
|
||||
|
||||
await writeSettings(settingsPath, settings);
|
||||
return { updated: true, settingsPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all hook entries owned by `source`. Used by the sync's orphan-
|
||||
* removal path so a skill that's no longer in the server set
|
||||
* un-registers its hooks too. Returns whether anything was changed.
|
||||
*/
|
||||
export async function removeManagedHooks(
|
||||
source: string,
|
||||
settingsPath: string = defaultSettingsPath(),
|
||||
): Promise<{ removed: boolean; settingsPath: string }> {
|
||||
const result = await applyManagedHooks(source, {}, settingsPath);
|
||||
return { removed: result.updated, settingsPath: result.settingsPath };
|
||||
}
|
||||
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT';
|
||||
}
|
||||
174
src/cli/tests/utils/hooks-materialiser.test.ts
Normal file
174
src/cli/tests/utils/hooks-materialiser.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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 { applyManagedHooks, removeManagedHooks, SOURCE_KEY } from '../../src/utils/hooks-materialiser.js';
|
||||
import { MARKER_KEY } from '../../src/utils/sessionhook.js';
|
||||
|
||||
describe('hooks-materialiser', () => {
|
||||
let tmp: string;
|
||||
let settings: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), 'mcpctl-hooks-'));
|
||||
settings = join(tmp, 'settings.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes a tagged hook from scratch when settings.json is missing', async () => {
|
||||
const result = await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo before' }],
|
||||
}, settings);
|
||||
|
||||
expect(result.updated).toBe(true);
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
expect(file.hooks.PreToolUse).toHaveLength(1);
|
||||
const entry = file.hooks.PreToolUse[0].hooks[0];
|
||||
expect(entry.command).toBe('echo before');
|
||||
expect(entry[MARKER_KEY]).toBe(true);
|
||||
expect(entry[SOURCE_KEY]).toBe('skill-a');
|
||||
});
|
||||
|
||||
it('coexists with hooks owned by other skills', async () => {
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo a' }],
|
||||
}, settings);
|
||||
await applyManagedHooks('skill-b', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo b' }],
|
||||
}, settings);
|
||||
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
|
||||
expect(all.find((e: { command: string }) => e.command === 'echo a')).toBeDefined();
|
||||
expect(all.find((e: { command: string }) => e.command === 'echo b')).toBeDefined();
|
||||
expect(all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves user-added hooks (no marker)', async () => {
|
||||
await mkdir(tmp, { recursive: true });
|
||||
await writeFile(settings, JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo user' }] }],
|
||||
},
|
||||
}));
|
||||
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo a' }],
|
||||
}, settings);
|
||||
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
|
||||
expect(all.find((e: { command: string }) => e.command === 'echo user')).toBeDefined();
|
||||
expect(all.find((e: { command: string; [k: string]: unknown }) => e.command === 'echo a' && e[MARKER_KEY] === true)).toBeDefined();
|
||||
});
|
||||
|
||||
it('updating a skill replaces its old entries (does not duplicate)', async () => {
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo old' }],
|
||||
}, settings);
|
||||
const second = await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo new' }],
|
||||
}, settings);
|
||||
|
||||
expect(second.updated).toBe(true);
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
|
||||
const ours = all.filter((e: { [k: string]: unknown }) => e[SOURCE_KEY] === 'skill-a');
|
||||
expect(ours).toHaveLength(1);
|
||||
expect((ours[0] as { command: string }).command).toBe('echo new');
|
||||
});
|
||||
|
||||
it('shrinking a skill drops events it no longer declares', async () => {
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo pre' }],
|
||||
PostToolUse: [{ type: 'command', command: 'echo post' }],
|
||||
}, settings);
|
||||
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo pre' }],
|
||||
// PostToolUse omitted → should be dropped
|
||||
}, settings);
|
||||
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
expect(file.hooks.PreToolUse).toBeDefined();
|
||||
expect(file.hooks.PostToolUse).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removeManagedHooks drops only the named source', async () => {
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo a' }],
|
||||
}, settings);
|
||||
await applyManagedHooks('skill-b', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo b' }],
|
||||
}, settings);
|
||||
|
||||
const removed = await removeManagedHooks('skill-a', settings);
|
||||
expect(removed.removed).toBe(true);
|
||||
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
|
||||
expect(all).toHaveLength(1);
|
||||
expect((all[0] as { command: string }).command).toBe('echo b');
|
||||
});
|
||||
|
||||
it('removeManagedHooks is a no-op when the source has no entries', async () => {
|
||||
const result = await removeManagedHooks('never-installed', settings);
|
||||
expect(result.removed).toBe(false);
|
||||
});
|
||||
|
||||
it('handles multiple hook events independently', async () => {
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo pre' }],
|
||||
PostToolUse: [{ type: 'command', command: 'echo post' }],
|
||||
SessionStart: [{ type: 'command', command: 'echo start' }],
|
||||
}, settings);
|
||||
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
expect(file.hooks.PreToolUse).toBeDefined();
|
||||
expect(file.hooks.PostToolUse).toBeDefined();
|
||||
expect(file.hooks.SessionStart).toBeDefined();
|
||||
});
|
||||
|
||||
it('idempotent — re-applying the same hooks reports updated=true on first call only', async () => {
|
||||
const first = await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo a' }],
|
||||
}, settings);
|
||||
expect(first.updated).toBe(true);
|
||||
|
||||
// Re-applying ALWAYS rewrites our entry (we don't deep-equal them
|
||||
// for "no change"), but the resulting file is byte-identical except
|
||||
// for ordering. The test just confirms the file remains valid + well-shaped.
|
||||
const second = await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo a' }],
|
||||
}, settings);
|
||||
// updated=true is acceptable here; we replaced+re-added our entry.
|
||||
expect(second.updated).toBe(true);
|
||||
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
const all = file.hooks.PreToolUse.flatMap((g: { hooks: Array<{ command: string; [k: string]: unknown }> }) => g.hooks);
|
||||
const ours = all.filter((e: { [k: string]: unknown }) => e[SOURCE_KEY] === 'skill-a');
|
||||
expect(ours).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('survives empty settings.json', async () => {
|
||||
await writeFile(settings, '');
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo a' }],
|
||||
}, settings);
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
expect(file.hooks.PreToolUse).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('survives JSONC line comments in settings.json', async () => {
|
||||
await writeFile(settings, '// preamble\n{ "hooks": {} }\n');
|
||||
await applyManagedHooks('skill-a', {
|
||||
PreToolUse: [{ type: 'command', command: 'echo a' }],
|
||||
}, settings);
|
||||
const file = JSON.parse(await readFile(settings, 'utf-8'));
|
||||
expect(file.hooks.PreToolUse).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user