Integrate skills + v7-visibility + mcpctl passwd (deployed) #75

Merged
michal merged 19 commits from integration/skills-v7-passwd into main 2026-06-16 22:16:49 +00:00
3 changed files with 384 additions and 3 deletions
Showing only changes of commit 7ebc8b22d1 - Show all commits

View File

@@ -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' &&

View 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';
}

View 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);
});
});