diff --git a/src/ac.ts b/src/ac.ts index 991e2b9..f5352be 100644 --- a/src/ac.ts +++ b/src/ac.ts @@ -159,6 +159,8 @@ function parseUpArgs(rest: string[]): UpArgs { interface InjectArgs { component: string | null; + /** Which kind of inject — set alongside `component` by the supplying flag. */ + kind: 'accessory' | 'bundle' | 'skill' | 'hook' | null; home: string | null; from: string | null; dryRun: boolean; @@ -171,15 +173,18 @@ interface InjectArgs { } /** - * Parse `suit inject` args (increment 1). + * Parse `suit inject` args. * - * `--bundle` is an alias of `--accessory`: a bundle is just a named accessory - * whose include block lists components. `--target owner/session` (NATS - * discovery) is a LATER increment — recorded here so the dispatcher can exit 2 - * with a "not yet implemented" message rather than silently ignoring it. + * Exactly ONE component-kind flag is required: `--accessory`/`--bundle` (a + * bundle is just a named accessory whose include block lists components), + * `--skill `, or `--hook ` (sugar for a single bare component). + * `--target owner/session` (NATS discovery) is a LATER increment — recorded + * here so the dispatcher can exit 2 with a "not yet implemented" message rather + * than silently ignoring it. */ function parseInjectArgs(rest: string[]): InjectArgs { let component: string | null = null; + let kind: InjectArgs['kind'] = null; let home: string | null = null; let from: string | null = null; let dryRun = false; @@ -205,11 +210,15 @@ function parseInjectArgs(rest: string[]): InjectArgs { flag = arg.slice(0, eq); eqValue = arg.slice(eq + 1); } - if (flag === '--accessory' || flag === '--bundle') { + if (flag === '--accessory' || flag === '--bundle' || flag === '--skill' || flag === '--hook') { const r = takeValue(i, eqValue); if (r.value === null) { err = err ?? `suit inject: ${flag} requires a value`; continue; } - if (component !== null) { err = err ?? 'suit inject: only one component may be injected at a time'; continue; } + if (component !== null) { err = err ?? 'suit inject: exactly one of --accessory/--bundle/--skill/--hook may be given'; continue; } component = r.value; + kind = flag === '--accessory' ? 'accessory' + : flag === '--bundle' ? 'bundle' + : flag === '--skill' ? 'skill' + : 'hook'; i = r.next; } else if (flag === '--home') { const r = takeValue(i, eqValue); @@ -239,7 +248,7 @@ function parseInjectArgs(rest: string[]): InjectArgs { err = err ?? `suit inject: unrecognized argument "${arg}"`; } } - return { component, home, from, dryRun, noReload, force, json, targetRequested, err }; + return { component, kind, home, from, dryRun, noReload, force, json, targetRequested, err }; } interface PrepareArgs { @@ -596,8 +605,8 @@ async function main(): Promise { process.stderr.write('suit inject: not yet implemented: use --home\n'); return 2; } - if (parsed.component === null) { - process.stderr.write('suit inject: --accessory (or --bundle) is required\n'); + if (parsed.component === null || parsed.kind === null) { + process.stderr.write('suit inject: exactly one of --accessory/--bundle/--skill/--hook is required\n'); return 2; } // Home resolution (increment 1): --home > $CLAUDE_PROJECT_DIR > cwd. @@ -607,6 +616,7 @@ async function main(): Promise { return runInject( { component: parsed.component, + kind: parsed.kind, home, contentDir, userDir: paths.userOverlayDir, diff --git a/src/lib/ac/compose.ts b/src/lib/ac/compose.ts index ca631dc..0b28821 100644 --- a/src/lib/ac/compose.ts +++ b/src/lib/ac/compose.ts @@ -130,6 +130,7 @@ async function emitForTarget( skillsDrop: string[], projectDir: string, repoConfig: Record>, + restrictTo: Set | undefined, ): Promise { const adapter = getAdapter(target); if (!adapter) { @@ -139,6 +140,15 @@ async function emitForTarget( const eligible = catalog.filter((c) => { if (!c.manifest.targets.includes(target)) return false; if (!adapter.supports(c)) return false; + // Explicit keep-list (suit inject): emit a component ONLY if it is named in + // the keep-set, keyed `:`. This REPLACES the skill-only dropSet + // filter — without it, the category/drop model leaves every non-skill + // component (hooks/agents/commands/rules/mcp) unfiltered, over-emitting the + // entire wardrobe. When `restrictTo` is undefined (suit up/prepare), behavior + // is unchanged. + if (restrictTo !== undefined) { + return restrictTo.has(`${c.manifest.type}:${c.manifest.name}`); + } if (c.manifest.type === 'skill' && dropSet.has(c.manifest.name)) return false; return true; }); @@ -217,6 +227,15 @@ export interface ComposeBundleArgs { projectDir: string; contentDir: string; userDir: string; + /** + * Explicit emission keep-set for `suit inject`. Keys are `:` + * (e.g. `"skill:ousterhout"`, `"agent:architect-review"`, `"hook:rtk-suggest"`). + * When provided, `emitForTarget` keeps a component ONLY if its `:` + * is in this set — replacing the category/drop model so inject emits exactly + * the declared components, not the whole catalog. When undefined (`suit + * up`/`prepare`), the standard category/drop filter applies unchanged. + */ + restrictTo?: Set; } export interface ComposeBundleDeps { @@ -313,6 +332,7 @@ export async function composeBundle( canonicalResolution.skillsDrop, args.projectDir, repoConfig as Record>, + args.restrictTo, ); allFiles.push(...targetFiles); } diff --git a/src/lib/ac/inject.ts b/src/lib/ac/inject.ts index f469f72..0399b3b 100644 --- a/src/lib/ac/inject.ts +++ b/src/lib/ac/inject.ts @@ -25,11 +25,26 @@ import { type InjectedComponent, } from '../lockfile.js'; import { composeBundle, type PendingFile } from './compose.js'; -import type { Target, ComponentType } from '../types.js'; +import { findAccessory } from '../accessory.js'; +import { discoverComponents } from '../discover.js'; +import type { Target, ComponentType, ComponentSource } from '../types.js'; + +/** + * Which kind of inject this is. `accessory`/`bundle` inject an accessory's whole + * declared include block; `skill`/`hook` inject a single bare component. + */ +export type InjectKind = 'accessory' | 'bundle' | 'skill' | 'hook'; export interface RunInjectArgs { - /** The accessory/bundle name to inject (`--accessory`/`--bundle`). */ + /** The component name to inject (accessory/bundle name, or a skill/hook name). */ component: string; + /** + * Which CLI flag supplied `component`. Drives the keep-set computation and the + * lockfile provenance label (accessory/bundle keep the bare name; skill/hook + * use a qualified `skill:` / `hook:` label so `suit current`/`off` + * know what kind was injected). + */ + kind: InjectKind; /** Target harness home dir — files are written here; lockfile at /.suit/lock.json. */ home: string; /** Wardrobe content dir (default = configured content dir; overridden by `--from`). */ @@ -146,16 +161,150 @@ async function fileExists(p: string): Promise { } } +/** A component's keep-set key (`:`). */ +function keepKey(type: ComponentType, name: string): string { + return `${type}:${name}`; +} + +/** + * Map an accessory `include`-block field to the ComponentType used as the + * keep-set key prefix. Note `include.rules` keys on the plural `rules` (the enum + * value), and `include.commands` keys on `command` (no catalog entry today, so + * it simply never matches — harmless). + */ +const INCLUDE_FIELD_TO_TYPE: Record = { + skills: 'skill', + rules: 'rules', + hooks: 'hook', + agents: 'agent', + commands: 'command', +}; + +export interface KeepSetResult { + /** `:` keys passed to composeBundle.restrictTo. */ + restrictTo: Set; + /** For accessory/bundle: the accessory name to pass to composeBundle.accessories. */ + accessories: string[]; + /** + * Targets to emit for. For a bare skill/hook (empty `accessories`) composeBundle + * cannot derive targets from any manifest union, so we supply them from the + * component's own `targets:`. For an accessory this is empty — composeBundle + * derives targets from the accessory manifest as usual. + */ + targets: Target[]; + /** The lockfile provenance label (`suit current`/`off` read this). */ + lockLabel: string; +} + +/** + * Resolve a bare skill/hook from the discovered catalog, asserting it exists and + * is the right type. Errors carry a clear message for the exit-1 not-found path. + */ +function resolveBareComponent( + catalog: ComponentSource[], + kind: 'skill' | 'hook', + name: string, +): ComponentSource { + const hit = catalog.find((c) => c.manifest.type === kind && c.manifest.name === name); + if (!hit) { + throw new Error(`suit inject: ${kind} "${name}" not found in wardrobe`); + } + return hit; +} + +/** + * Compute the explicit emission keep-set for an inject. For accessory/bundle the + * keep-set is the accessory's declared `include` block (each field mapped to its + * ComponentType prefix) plus its `skill_include` names. For a bare skill/hook the + * keep-set is the single `:` key. + * + * Every named component is validated against the discovered catalog; an unknown + * name throws (the caller maps that to exit 1). The accessory itself contributes + * ONLY its declared components — never the whole catalog. + */ +export async function computeKeepSet( + kind: InjectKind, + component: string, + catalog: ComponentSource[], + dirs: { projectDir: string; userDir: string; builtinDir: string }, +): Promise { + if (kind === 'skill' || kind === 'hook') { + const hit = resolveBareComponent(catalog, kind, component); + return { + restrictTo: new Set([keepKey(kind, component)]), + accessories: [], + targets: hit.manifest.targets, + lockLabel: `${kind}:${component}`, + }; + } + + // accessory / bundle: read the accessory manifest and build the keep-set from + // its include block + skill_include. findAccessory also handles the + // accessory-as-role fall-through (a bare skill/hook/etc. named via --accessory). + const found = await findAccessory(component, dirs); + const m = found.manifest; + const restrictTo = new Set(); + + // Accessory keep-set = its declared `include` block. (Unlike outfits/cuts/fits, + // the accessory schema has no `skill_include` — `skills:` inside `include` is + // the canonical accessory mechanism, so there's nothing extra to union here.) + const include = (m.include ?? {}) as Record; + for (const [field, type] of Object.entries(INCLUDE_FIELD_TO_TYPE)) { + for (const name of include[field] ?? []) { + restrictTo.add(`${type}:${name}`); + } + } + + // Validate every named component exists in the catalog. `command:` keys are + // exempt — the catalog doesn't walk commands/ yet, so they cannot be matched + // and would spuriously fail an existence check. + for (const key of restrictTo) { + const sep = key.indexOf(':'); + const type = key.slice(0, sep) as ComponentType | 'command'; + const name = key.slice(sep + 1); + if (type === 'command') continue; + const exists = catalog.some((c) => c.manifest.type === type && c.manifest.name === name); + if (!exists) { + throw new Error( + `suit inject: ${type} "${name}" (declared by accessory "${component}") not found in wardrobe`, + ); + } + } + + return { restrictTo, accessories: [component], targets: [], lockLabel: component }; +} + export async function runInject(args: RunInjectArgs, deps: RunInjectDeps): Promise { - // Compose the accessory's files with no base outfit — composeBundle handles - // the outfit-less path; resolution.ts resolves the accessory's include block. - // Errors (component-not-found, strict-include) propagate to the top-level CLI - // catch, mirroring `suit up`. + // Compute the explicit emission keep-set: inject scopes output to exactly the + // declared components (an accessory's include block, or a single bare + // skill/hook), NOT the whole wardrobe. This is the mechanism that both fixes + // the over-emission bug and powers --skill/--hook. A not-found component + // throws here → caught below → exit 1. + const catalog = await discoverComponents(args.contentDir); + let keepSet: KeepSetResult; + try { + keepSet = await computeKeepSet(args.kind, args.component, catalog, { + projectDir: args.home, + userDir: args.userDir, + builtinDir: args.contentDir, + }); + } catch (err) { + deps.stderr(`${err instanceof Error ? err.message : String(err)}\n`); + return 1; + } + const lockLabel = keepSet.lockLabel; + + // Compose with no base outfit and the explicit keep-set threaded through to + // emitForTarget. All of composeBundle's post-processing (dedupe, additive + // marker-wrap, path redirects) stays intact — off-cleanup + idempotency depend + // on additive entries being marker-wrapped with stable block-shas. const composed = await composeBundle( { outfit: null, cut: null, - accessories: [args.component], + accessories: keepSet.accessories, + restrictTo: keepSet.restrictTo, + targets: keepSet.targets, projectDir: args.home, contentDir: args.contentDir, userDir: args.userDir, @@ -202,13 +351,13 @@ export async function runInject(args: RunInjectArgs, deps: RunInjectDeps): Promi // Idempotency: if a prior injection of THIS component recorded the exact same // file set (same paths, shas, and modes — order-insensitive), this is a no-op. - const priorEntry = (priorLock?.injected ?? []).find((e) => e.component === args.component); + const priorEntry = (priorLock?.injected ?? []).find((e) => e.component === lockLabel); const allUnchanged = pending.length > 0 && priorEntry !== undefined && sameLockEntries(priorEntry.files, injectedFiles); if (allUnchanged) { report(args, deps, { status: 'unchanged', - component: args.component, + component: lockLabel, home: args.home, files: pending.length, reload: reloadReport, @@ -245,7 +394,7 @@ export async function runInject(args: RunInjectArgs, deps: RunInjectDeps): Promi if (args.dryRun) { report(args, deps, { status: 'dry-run', - component: args.component, + component: lockLabel, home: args.home, files: pending.length, reload: reloadReport, @@ -266,14 +415,14 @@ export async function runInject(args: RunInjectArgs, deps: RunInjectDeps): Promi // idempotency check and is reused here verbatim. const now = new Date().toISOString(); const newEntry: InjectedComponent = { - component: args.component, + component: lockLabel, injectedAt: now, files: injectedFiles, }; let lock: Lockfile; if (priorLock) { - const injected = (priorLock.injected ?? []).filter((e) => e.component !== args.component); + const injected = (priorLock.injected ?? []).filter((e) => e.component !== lockLabel); injected.push(newEntry); lock = { ...priorLock, injected }; } else { @@ -289,7 +438,7 @@ export async function runInject(args: RunInjectArgs, deps: RunInjectDeps): Promi report(args, deps, { status: 'injected', - component: args.component, + component: lockLabel, home: args.home, files: pending.length, reload: reloadReport, diff --git a/src/tests/ac/inject.test.ts b/src/tests/ac/inject.test.ts index b9966f5..a0e0911 100644 --- a/src/tests/ac/inject.test.ts +++ b/src/tests/ac/inject.test.ts @@ -107,6 +107,71 @@ Norman's design principles apply to every interface. return root; } +/** + * Wardrobe mirroring the REAL `philosophy` accessory: 6 declared skills + * (ousterhout/tigerstyle/farley/hipp/norman/vitaly) + 1 declared agent + * (architect-review), PLUS unrelated catalog components (golang-patterns, + * publish-to-npm skills; a stray hook + agent) that the accessory does NOT + * declare. The over-emission bug emitted the whole catalog; the fix must emit + * ONLY the 6 declared skills + 1 agent. + */ +const PHIL_SKILLS = ['ousterhout', 'tigerstyle', 'farley', 'hipp', 'norman', 'vitaly']; +const UNRELATED_SKILLS = ['golang-patterns', 'publish-to-npm']; + +async function writeSkill(root: string, name: string): Promise { + await fs.mkdir(path.join(root, 'skills', name), { recursive: true }); + await fs.writeFile( + path.join(root, 'skills', name, 'SKILL.md'), + `---\nname: ${name}\nversion: 1.0.0\ntype: skill\ndescription: ${name} skill\ntargets: [claude-code]\n---\n${name} body\n`, + ); +} + +async function mkWardrobePhilosophy(): Promise { + const root = await mkdirT('suit-inject-phil-'); + + await fs.mkdir(path.join(root, 'accessories', 'philosophy'), { recursive: true }); + await fs.writeFile( + path.join(root, 'accessories', 'philosophy', 'accessory.md'), + `--- +name: philosophy +version: 1.0.0 +type: accessory +description: philosophy pack +targets: [claude-code] +include: + skills: [${PHIL_SKILLS.join(', ')}] + rules: [] + hooks: [] + agents: [architect-review] + commands: [] +--- +philosophy bundle body +`, + ); + + for (const s of [...PHIL_SKILLS, ...UNRELATED_SKILLS]) await writeSkill(root, s); + + // The declared agent + an undeclared agent (proves agents are also scoped). + for (const a of ['architect-review', 'stray-agent']) { + await fs.mkdir(path.join(root, 'agents', a), { recursive: true }); + await fs.writeFile( + path.join(root, 'agents', a, 'AGENT.md'), + `---\nname: ${a}\nversion: 1.0.0\ntype: agent\ndescription: ${a}\ntargets: [claude-code]\n---\n${a} body\n`, + ); + } + + // A standalone hook the accessory does NOT declare — must NOT be emitted. + // The claude-code adapter reads the script the hook references, so it must exist. + await fs.mkdir(path.join(root, 'hooks', 'rtk-suggest'), { recursive: true }); + await fs.writeFile(path.join(root, 'hooks', 'rtk-suggest', 'rtk-suggest.sh'), '#!/bin/sh\necho hi\n'); + await fs.writeFile( + path.join(root, 'hooks', 'rtk-suggest', 'HOOK.md'), + `---\nname: rtk-suggest\nversion: 1.0.0\ntype: hook\ndescription: rtk suggest hook\ntargets: [claude-code]\nhooks:\n PreToolUse:\n command: rtk-suggest.sh\n---\nhook body\n`, + ); + + return root; +} + interface Capture { out: string[]; err: string[]; @@ -132,6 +197,7 @@ describe('runInject — materialize + lockfile', () => { const code = await runInject( { component: 'release', + kind: 'accessory', home, contentDir: wardrobe, userDir, @@ -179,7 +245,7 @@ describe('runInject — materialize + lockfile', () => { const c1 = capture(); await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, { stdout: c1.push, stderr: c1.pushE }, ); const lock1 = await readLockfile(home); @@ -192,7 +258,7 @@ describe('runInject — materialize + lockfile', () => { const c2 = capture(); const code = await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, { stdout: c2.push, stderr: c2.pushE }, ); @@ -218,7 +284,7 @@ describe('runInject — materialize + lockfile', () => { const c1 = capture(); const code1 = await runInject( - { component: 'philosophy', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { component: 'philosophy', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, { stdout: c1.push, stderr: c1.pushE }, ); expect(c1.err.join('')).toBe(''); @@ -237,7 +303,7 @@ describe('runInject — materialize + lockfile', () => { const c2 = capture(); const code2 = await runInject( - { component: 'philosophy', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { component: 'philosophy', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, { stdout: c2.push, stderr: c2.pushE }, ); expect(code2).toBe(0); @@ -259,7 +325,7 @@ describe('runInject — materialize + lockfile', () => { const c1 = capture(); const refused = await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, { stdout: c1.push, stderr: c1.pushE }, ); expect(refused).toBe(1); @@ -268,7 +334,7 @@ describe('runInject — materialize + lockfile', () => { const c2 = capture(); const forced = await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: true, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: true, json: false }, { stdout: c2.push, stderr: c2.pushE }, ); expect(forced).toBe(0); @@ -285,7 +351,7 @@ describe('runInject — materialize + lockfile', () => { const cap = capture(); await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, { stdout: cap.push, stderr: cap.pushE }, ); @@ -313,7 +379,7 @@ describe('runInject — materialize + lockfile', () => { const cap = capture(); await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, { stdout: cap.push, stderr: cap.pushE }, ); @@ -331,7 +397,7 @@ describe('runInject — materialize + lockfile', () => { const cap = capture(); const code = await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: true, noReload: false, force: false, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: true, noReload: false, force: false, json: false }, { stdout: cap.push, stderr: cap.pushE }, ); expect(code).toBe(0); @@ -347,7 +413,7 @@ describe('runInject — materialize + lockfile', () => { const cap = capture(); await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: true, force: false, json: false }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: true, force: false, json: false }, { stdout: cap.push, stderr: cap.pushE }, ); expect(cap.out.join('')).toMatch(/reload:\s+skipped/); @@ -360,7 +426,7 @@ describe('runInject — materialize + lockfile', () => { const cap = capture(); await runInject( - { component: 'release', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: true }, + { component: 'release', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: true }, { stdout: cap.push, stderr: cap.pushE }, ); const parsed = JSON.parse(cap.out.join('')); @@ -371,6 +437,120 @@ describe('runInject — materialize + lockfile', () => { }); }); +async function listSkillDirs(home: string): Promise { + try { + const entries = await fs.readdir(path.join(home, '.claude', 'skills'), { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(); + } catch { + return []; + } +} + +describe('runInject — scoped emission (keep-set)', () => { + it('BUG REGRESSION: --accessory philosophy emits EXACTLY its declared components, not the whole catalog', async () => { + const wardrobe = await mkWardrobePhilosophy(); + const home = await mkdirT('suit-inject-home-'); + const userDir = await mkdirT('suit-inject-user-'); + const cap = capture(); + + const code = await runInject( + { component: 'philosophy', kind: 'accessory', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(cap.err.join('')).toBe(''); + expect(code).toBe(0); + + // EXACTLY the 6 declared philosophy skills landed — no more, no less. + const skills = await listSkillDirs(home); + expect(skills).toEqual([...PHIL_SKILLS].sort()); + expect(skills).toHaveLength(6); // was ~108 before the fix + + // Unrelated skills are ABSENT. + for (const s of UNRELATED_SKILLS) { + expect(skills).not.toContain(s); + } + + // The declared architect-review agent IS present; the stray one is ABSENT. + expect(await fileExistsT(path.join(home, '.claude', 'agents', 'architect-review.md'))).toBe(true); + expect(await fileExistsT(path.join(home, '.claude', 'agents', 'stray-agent.md'))).toBe(false); + + // The undeclared hook is ABSENT (the over-emission bug emitted hooks too). + const settings = await fs + .readFile(path.join(home, '.claude', 'settings.local.json'), 'utf8') + .catch(() => ''); + expect(settings).not.toMatch(/rtk-suggest/); + }); + + it('--skill ousterhout emits exactly the ousterhout skill and nothing else', async () => { + const wardrobe = await mkWardrobePhilosophy(); + const home = await mkdirT('suit-inject-home-'); + const userDir = await mkdirT('suit-inject-user-'); + const cap = capture(); + + const code = await runInject( + { component: 'ousterhout', kind: 'skill', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(cap.err.join('')).toBe(''); + expect(code).toBe(0); + + const skills = await listSkillDirs(home); + expect(skills).toEqual(['ousterhout']); + expect(skills).not.toContain('tigerstyle'); + + // Lockfile label is qualified `skill:`. + const lock = await readLockfile(home); + expect(lock!.injected![0].component).toBe('skill:ousterhout'); + }); + + it('--hook rtk-suggest emits exactly that hook', async () => { + const wardrobe = await mkWardrobePhilosophy(); + const home = await mkdirT('suit-inject-home-'); + const userDir = await mkdirT('suit-inject-user-'); + const cap = capture(); + + const code = await runInject( + { component: 'rtk-suggest', kind: 'hook', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(cap.err.join('')).toBe(''); + expect(code).toBe(0); + + // No skills emitted at all. + expect(await listSkillDirs(home)).toEqual([]); + + // The hook landed in settings, and the lockfile label is qualified. + const settings = await fs.readFile(path.join(home, '.claude', 'settings.local.json'), 'utf8'); + expect(settings).toMatch(/rtk-suggest|echo hi/); + const lock = await readLockfile(home); + expect(lock!.injected![0].component).toBe('hook:rtk-suggest'); + }); + + it('non-existent skill → exit 1 with a clear message', async () => { + const wardrobe = await mkWardrobePhilosophy(); + const home = await mkdirT('suit-inject-home-'); + const userDir = await mkdirT('suit-inject-user-'); + const cap = capture(); + + const code = await runInject( + { component: 'does-not-exist', kind: 'skill', home, contentDir: wardrobe, userDir, dryRun: false, noReload: false, force: false, json: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(code).toBe(1); + expect(cap.err.join('')).toMatch(/skill "does-not-exist" not found in wardrobe/); + expect(await readLockfile(home)).toBeNull(); + }); +}); + +async function fileExistsT(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + describe('reloadDecision', () => { it('claude-code skill → not-required', () => { expect(reloadDecision('claude-code', new Set(['skill']))).toBe('not-required');