Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions src/ac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <name>`, or `--hook <name>` (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;
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -596,8 +605,8 @@ async function main(): Promise<number> {
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) <name> is required\n');
if (parsed.component === null || parsed.kind === null) {
process.stderr.write('suit inject: exactly one of --accessory/--bundle/--skill/--hook <name> is required\n');
return 2;
}
// Home resolution (increment 1): --home > $CLAUDE_PROJECT_DIR > cwd.
Expand All @@ -607,6 +616,7 @@ async function main(): Promise<number> {
return runInject(
{
component: parsed.component,
kind: parsed.kind,
home,
contentDir,
userDir: paths.userOverlayDir,
Expand Down
20 changes: 20 additions & 0 deletions src/lib/ac/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ async function emitForTarget(
skillsDrop: string[],
projectDir: string,
repoConfig: Record<string, Record<string, unknown>>,
restrictTo: Set<string> | undefined,
): Promise<PendingFile[]> {
const adapter = getAdapter(target);
if (!adapter) {
Expand All @@ -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 `<type>:<name>`. 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;
});
Expand Down Expand Up @@ -217,6 +227,15 @@ export interface ComposeBundleArgs {
projectDir: string;
contentDir: string;
userDir: string;
/**
* Explicit emission keep-set for `suit inject`. Keys are `<type>:<name>`
* (e.g. `"skill:ousterhout"`, `"agent:architect-review"`, `"hook:rtk-suggest"`).
* When provided, `emitForTarget` keeps a component ONLY if its `<type>:<name>`
* 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<string>;
}

export interface ComposeBundleDeps {
Expand Down Expand Up @@ -313,6 +332,7 @@ export async function composeBundle(
canonicalResolution.skillsDrop,
args.projectDir,
repoConfig as Record<string, Record<string, unknown>>,
args.restrictTo,
);
allFiles.push(...targetFiles);
}
Expand Down
175 changes: 162 additions & 13 deletions src/lib/ac/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` / `hook:<name>` label so `suit current`/`off`
* know what kind was injected).
*/
kind: InjectKind;
/** Target harness home dir — files are written here; lockfile at <home>/.suit/lock.json. */
home: string;
/** Wardrobe content dir (default = configured content dir; overridden by `--from`). */
Expand Down Expand Up @@ -146,16 +161,150 @@ async function fileExists(p: string): Promise<boolean> {
}
}

/** A component's keep-set key (`<type>:<name>`). */
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<string, ComponentType | 'command'> = {
skills: 'skill',
rules: 'rules',
hooks: 'hook',
agents: 'agent',
commands: 'command',
};

export interface KeepSetResult {
/** `<type>:<name>` keys passed to composeBundle.restrictTo. */
restrictTo: Set<string>;
/** 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 `<type>:<name>` 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<KeepSetResult> {
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<string>();

// 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<string, string[] | undefined>;
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<number> {
// 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand Down
Loading