feat(cli): metadata.hooks materialisation in mcpctl skills sync
Closes the second-biggest deferred item from PR-5: skills can declare
PreToolUse / PostToolUse / SessionStart / Stop / SubagentStop /
Notification hooks in metadata.hooks, and `mcpctl skills sync` now
registers them in ~/.claude/settings.json with proper source-tagging.
## Why this needs source-tagging
mcpctl already managed ONE entry in settings.json (the SessionStart
hook for `mcpctl skills sync --quiet`, set up by PR-5 via
`installManagedSessionHook`). That entry uses `_mcpctl_managed: true`
to identify itself for idempotent updates.
Skill-declared hooks need finer scoping: skill A and skill B could
both register PreToolUse hooks, and removing skill A must not touch
skill B's. So entries written by `applyManagedHooks` carry both:
_mcpctl_managed: true ← same flag the SessionStart hook uses
_mcpctl_source: "<skill-name>" ← differentiates per-skill
Removing a skill (orphan cleanup) drops every entry tagged with
that skill's name. User-added entries (no marker) are preserved
verbatim. The earlier session-hook installer keeps working: its
`_mcpctl_managed: true` lacks the source tag, so the hooks-materialiser
ignores it on per-skill operations.
## Behaviour
- Skill installs/updates: applyManagedHooks(skillName, metadata.hooks)
is called after files are atomically materialised. The function
reads settings.json, drops this skill's previous entries from every
declared event (and from any event it previously had entries in but
no longer declares — so a skill can shrink scope), then re-inserts
the new tagged set.
- Skill orphan removal: removeManagedHooks(skillName) drops every
entry owned by the skill. Other skills + user hooks unaffected.
- Failure handling: hooks errors are logged via the warn() callback
but do not fail the sync. Same shape as postInstall — scoped, not
fatal.
## Files
### Added
- src/cli/src/utils/hooks-materialiser.ts (~140 LOC)
- src/cli/tests/utils/hooks-materialiser.test.ts (~165 LOC, 11 tests
covering: write-from-scratch, multi-source coexistence, user-hook
preservation, update replaces (not duplicates), shrink drops events,
remove targets only the named source, multiple events independent,
idempotent, empty/JSONC settings.json tolerance)
### Edited
- src/cli/src/commands/skills.ts: applyOne calls applyManagedHooks
when metadata.hooks is set; calls removeManagedHooks(skillName) when
a previously-installed skill no longer declares hooks; orphan
removal also drops the skill's hooks. New SyncResult field
`hooksApplied`. Earlier `meta` declaration deduped (it was redeclared
in the postInstall block).
## Verification
164 test files / 2182 tests green (up from 2171).
End-to-end on a real machine after this PR ships:
```yaml
# skill metadata.yaml
hooks:
PreToolUse:
- type: command
command: "audit-pretool.sh"
SessionStart:
- type: command
command: "claude-greeting.sh"
```
```
mcpctl skills sync
# → mcpctl: 1 installed, 1 hooks applied
jq '.hooks' ~/.claude/settings.json
# → entries tagged with _mcpctl_managed + _mcpctl_source
```
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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