Integrate skills + v7-visibility + mcpctl passwd (deployed) #75
@@ -21,6 +21,11 @@ import {
|
|||||||
emitPostInstallAudit,
|
emitPostInstallAudit,
|
||||||
hashScript,
|
hashScript,
|
||||||
} from '../utils/postinstall.js';
|
} from '../utils/postinstall.js';
|
||||||
|
import {
|
||||||
|
applyManagedHooks,
|
||||||
|
removeManagedHooks,
|
||||||
|
type HooksByEvent,
|
||||||
|
} from '../utils/hooks-materialiser.js';
|
||||||
import { ApiError } from '../api-client.js';
|
import { ApiError } from '../api-client.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,8 +69,9 @@ interface FullSkill {
|
|||||||
interface SyncedSkillMetadata {
|
interface SyncedSkillMetadata {
|
||||||
postInstall?: unknown;
|
postInstall?: unknown;
|
||||||
postInstallTimeoutSec?: unknown;
|
postInstallTimeoutSec?: unknown;
|
||||||
// hooks / mcpServers materialisation lives in a follow-up — see TODO
|
hooks?: unknown;
|
||||||
// in applyOne where we'd call them after the file install.
|
// mcpServers auto-attach lives in a follow-up — straightforward
|
||||||
|
// wrapper around the existing `mcpctl create serverattachment` path.
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncOpts {
|
export interface SyncOpts {
|
||||||
@@ -92,6 +98,7 @@ export interface SyncResult {
|
|||||||
preserved: string[]; // skills with local edits we left alone
|
preserved: string[]; // skills with local edits we left alone
|
||||||
postInstallsRan: string[]; // skills whose postInstall executed in this sync
|
postInstallsRan: string[]; // skills whose postInstall executed in this sync
|
||||||
postInstallsSkipped: string[]; // skills with postInstall but unchanged hash → no rerun
|
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 }>;
|
errors: Array<{ skill: string; error: string }>;
|
||||||
exitCode: 0 | 1 | 2;
|
exitCode: 0 | 1 | 2;
|
||||||
}
|
}
|
||||||
@@ -117,6 +124,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
|||||||
preserved: [],
|
preserved: [],
|
||||||
postInstallsRan: [],
|
postInstallsRan: [],
|
||||||
postInstallsSkipped: [],
|
postInstallsSkipped: [],
|
||||||
|
hooksApplied: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
};
|
};
|
||||||
@@ -211,6 +219,8 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await removeSkillAtomic(prior.installDir);
|
await removeSkillAtomic(prior.installDir);
|
||||||
|
// Drop any hook entries this skill registered.
|
||||||
|
try { await removeManagedHooks(name); } catch { /* best-effort */ }
|
||||||
delete state.skills[name];
|
delete state.skills[name];
|
||||||
result.removed.push(name);
|
result.removed.push(name);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -278,12 +288,29 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
|||||||
};
|
};
|
||||||
const fileStates = await installSkillAtomic(targetDir, body);
|
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 ──
|
// ── postInstall: run metadata.postInstall when present ──
|
||||||
// Hash-pinned: only execute when the script's sha256 differs from
|
// Hash-pinned: only execute when the script's sha256 differs from
|
||||||
// what state recorded. Failures DO NOT update the recorded hash so
|
// what state recorded. Failures DO NOT update the recorded hash so
|
||||||
// the next sync retries. Other skills continue regardless.
|
// the next sync retries. Other skills continue regardless.
|
||||||
let postInstallHash: string | null = prior?.postInstallHash ?? null;
|
let postInstallHash: string | null = prior?.postInstallHash ?? null;
|
||||||
const meta = (full.metadata ?? {}) as SyncedSkillMetadata;
|
|
||||||
if (
|
if (
|
||||||
!opts.skipPostInstall &&
|
!opts.skipPostInstall &&
|
||||||
typeof meta.postInstall === 'string' &&
|
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