fix(daemon): discover skills at runtime-truth filesystem paths during profile resolution

Closes the gap where the profile resolver only saw skills declared in
agent.yaml's resources.skills + imports — not skills present at the
runtime-truth filesystem locations the harness already loads from.
Operators can now drop any structurally-valid skill at the
user-library or rig-bundled paths and have it resolve through profile
validation, not just at runtime.

Behavior:
- New module packages/daemon/src/domain/skill-discovery.ts walks 5
  scan roots per runtime (claude-code targets ~/.openrig/skills/,
  ~/.claude/skills/, <cwd>/.claude/skills/, <specInstallDir>/skills/;
  codex targets ~/.openrig/skills/, ~/.agents/skills/,
  <cwd>/.agents/skills/, <specInstallDir>/skills/).
- Each candidate directory must contain a SKILL.md with a
  ---delimited YAML frontmatter block, non-empty `name` +
  `description` strings, and a non-empty body. The validator's
  question is "would the runtime actually load this," not "is this ID
  in the daemon shared bundle."
- Rejections surface as { path, reason } records so callers can show
  clear errors when an operator drops a malformed SKILL.md.
- Profile resolver augments its resource pool with discovered skills
  AFTER buildResourcePool, respecting rig-local-wins precedence: a
  skill ID declared in agent.yaml's resources.skills (or imports)
  beats any same-id discovered skill. Among discovery roots, the
  most-specific path wins (rig-bundled at cwd > spec-install-dir >
  runtime-specific user library > shared user-spec library).
- ResolutionContext gains an optional homedir field that defaults to
  os.homedir() in production; tests inject a fixture root.
- Runtime + cwd computation moved earlier in resolveNodeConfig so the
  filesystem scan targets the right paths before resolveProfileUses
  queries the augmented pool.

Tests:
- 18 unit tests in skill-discovery.test.ts cover frontmatter
  acceptance/rejection (6), per-runtime path scanning including
  cross-runtime exclusion (8), structural rejection (2), and the
  SkillResource shape contract (2).
- 5 integration tests in profile-resolver.test.ts cover discovered-
  only resolution at ~/.claude/skills/, rig-bundled cwd discovery,
  rig-local resources.skills precedence over a same-id discovered
  skill, codex runtime path scanning, and the preserved
  skill-not-found error path.

Verification:
- npx tsc --noEmit on daemon / cli / ui: all clean
- npx vitest run on daemon suite: 3141 pass, 1 pre-existing failure
  (rsi-v2-starter-spec-content non-qa-done assertion last touched in
  5bdda4f, unrelated to this slice)

Refs: substrate slice ack + walk at substrate/shared-docs/openrig-work/missions/bug-fix/slices/daemon-profile-resolver-skill-discovery/.

SC-29 EXCEPTION #7 declared (substantive daemon code change adding a
filesystem scan + structural validator + resource-pool augmentation;
not allowlist-only and not additive payload field; new code path).
Ledger position confirmed by checking #5 + #6 declared in c7c9751
project-observability merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 03:35:33 -07:00
1 parent 1644e742ac
commit 1482aef986
4 files changed
+604 -9

No files matched your search

+44 -9
View File
@@ -1,4 +1,5 @@
import nodePath from "node:path";
import { homedir as osHomedir } from "node:os";
import type {
AgentSpec, AgentResources, ProfileSpec, LifecycleDefaults,
RigSpec, RigSpecPod, RigSpecPodMember, StartupBlock,
@@ -6,6 +7,7 @@ import type {
} from "./types.js";
import type { ResolvedAgentSpec, ResourceCollision } from "./agent-resolver.js";
import { resolveStartup } from "./startup-resolver.js";
import { discoverSkillsForRuntime, type SkillRuntime } from "./skill-discovery.js";
// -- Types --
@@ -48,6 +50,11 @@ export interface ResolutionContext {
pod: RigSpecPod;
rig: RigSpec;
operatorStartup?: StartupBlock;
/** V0.3.0 daemon-skill-discovery (SC-29 #7): operator home directory
* used to scan filesystem-discovered skills (~/.openrig/skills/,
* ~/.claude/skills/, ~/.agents/skills/). Defaults to os.homedir()
* in production; tests inject a fixture root. */
homedir?: string;
}
export type ResolutionResult =
@@ -95,30 +102,58 @@ export function resolveNodeConfig(ctx: ResolutionContext): ResolutionResult {
// 2. Build combined resource pool
const pool = buildResourcePool(baseSpec, importedSpecs);
// 3. Resolve profile uses against pool
const selectedResult = resolveProfileUses(profile, pool, spec.name, errors);
if (errors.length > 0) {
return { ok: false, errors };
}
// 4. Resolve runtime (member authoritative)
// V0.3.0 daemon-skill-discovery (SC-29 #7): runtime + cwd must be
// resolved BEFORE the pool is queried, so the filesystem skill scan
// targets the right runtime's path layout (claude-code → .claude/;
// codex → .agents/) at the right cwd. Order swapped from prior
// versions where runtime/cwd were computed AFTER the pool.
const runtime = member.runtime
?? profile.preferences?.runtime
?? spec.defaults?.runtime
?? "claude-code";
// 5. Resolve model (member overrides)
const model = member.model
?? profile.preferences?.model
?? spec.defaults?.model;
// 6. Resolve cwd (member authoritative, required)
const cwd = ctx.cwdOverride
? nodePath.resolve(ctx.cwdOverride)
: ctx.specRoot
? (nodePath.isAbsolute(member.cwd) ? member.cwd : nodePath.resolve(ctx.specRoot, member.cwd))
: member.cwd;
// 2b. Augment the pool with filesystem-discovered skills. Rig-local
// resources.skills (already in the pool from buildResourcePool) win
// over discovered same-id entries — the most-specific-wins
// precedence: rig-local > spec-install-dir bundled > rig-cwd
// bundled > runtime-specific user library > shared user-spec
// library. The internal precedence among discovery roots lives in
// skill-discovery.listScanRoots; here we only enforce that
// rig-local-declared resources are not overwritten.
if (runtime === "claude-code" || runtime === "codex") {
const discovery = discoverSkillsForRuntime({
runtime: runtime as SkillRuntime,
homedir: ctx.homedir ?? osHomedir(),
cwd,
specInstallDir: ctx.specRoot,
});
for (const discovered of discovery.skills) {
if (pool.skills.has(discovered.id)) continue; // rig-local-wins
pool.skills.set(discovered.id, [{
effectiveId: discovered.id,
sourceSpec: "discovered",
sourcePath: discovered.path,
resource: discovered,
}]);
}
}
// 3. Resolve profile uses against the augmented pool
const selectedResult = resolveProfileUses(profile, pool, spec.name, errors);
if (errors.length > 0) {
return { ok: false, errors };
}
// 7. Resolve restorePolicy with narrowing
const restorePolicyResult = resolveRestorePolicy(spec, profile, member);
if (!restorePolicyResult.ok) {
@@ -0,0 +1,194 @@
// V0.3.0 daemon-skill-discovery — filesystem skill scan + structural
// validation. Closes the gap where profile-resolver only saw skills
// declared in `agent.yaml`'s `resources.skills` + imports, not skills
// dropped at user-library or rig-bundled paths.
//
// The validator's question is now "would Claude Code or Codex actually
// load this when it sees the directory?" — i.e., is there a SKILL.md
// with a name + description + body. The daemon's hardcoded shared
// bundle is no longer the gate.
//
// SC-29 EXCEPTION #7 declared in slice ACK §5.
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import type { SkillResource } from "./types.js";
export type SkillRuntime = "claude-code" | "codex";
export interface SkillDiscoveryPaths {
runtime: SkillRuntime;
/** Operator home dir (resolved by the daemon at startup, NOT read
* here via os.homedir() so tests can inject a fixture root). */
homedir: string;
/** The agent's resolved working directory — rig-bundled skills live
* under <cwd>/.claude/skills/ or <cwd>/.agents/skills/. */
cwd: string;
/** The rig-spec install dir — bundled domain skills live under
* <specInstallDir>/skills/<name>/. Optional: undefined means the
* rig was installed in-place and no separate install-dir applies. */
specInstallDir?: string;
}
export interface SkillFrontmatter {
name: string;
description: string;
/** Any other fields the runtime may consume (allowed-tools, model,
* etc.) pass through unchecked — they are not load-bearing for
* daemon resource validation. */
[key: string]: unknown;
}
export type ParseResult =
| { ok: true; frontmatter: SkillFrontmatter; body: string }
| { ok: false; reason: string };
export interface SkillRejection {
path: string;
reason: string;
}
export interface SkillDiscoveryResult {
skills: SkillResource[];
rejected: SkillRejection[];
}
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
/** Parse a SKILL.md document into frontmatter + body, validating the
* shape Claude Code and Codex both require: a YAML frontmatter block
* delimited by `---` lines, with at minimum `name` + `description`
* fields, plus a non-empty body. */
export function parseSkillFrontmatter(content: string): ParseResult {
const match = FRONTMATTER_RE.exec(content);
if (!match) {
return { ok: false, reason: "no YAML frontmatter delimited by --- lines" };
}
const [, fmText, body] = match;
let parsed: unknown;
try {
parsed = parseYaml(fmText!);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `frontmatter YAML parse error: ${msg}` };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, reason: "frontmatter is not a YAML mapping" };
}
const fm = parsed as Record<string, unknown>;
if (typeof fm.name !== "string" || fm.name.trim().length === 0) {
return { ok: false, reason: "frontmatter missing required `name` field" };
}
if (typeof fm.description !== "string" || fm.description.trim().length === 0) {
return { ok: false, reason: "frontmatter missing required `description` field" };
}
if (!body || body.trim().length === 0) {
return { ok: false, reason: "SKILL.md body is empty (the runtime would have nothing to load)" };
}
return {
ok: true,
frontmatter: { ...fm, name: fm.name as string, description: fm.description as string } as SkillFrontmatter,
body,
};
}
/** Discover skills for a runtime by scanning the canonical filesystem
* paths and structurally validating each candidate. Returns the
* accepted skills as SkillResource records (id from frontmatter; path
* pointing at the skill directory) plus a list of rejections so the
* caller can surface clear errors when validation fails. */
export function discoverSkillsForRuntime(paths: SkillDiscoveryPaths): SkillDiscoveryResult {
const scanRoots = listScanRoots(paths);
const skills: SkillResource[] = [];
const rejected: SkillRejection[] = [];
const seenIds = new Set<string>();
for (const root of scanRoots) {
if (!existsSync(root)) continue;
let entries: string[];
try {
entries = readdirSync(root);
} catch {
// Permission / IO error — silent skip; this is best-effort.
continue;
}
for (const entry of entries) {
const skillDir = join(root, entry);
let stat;
try { stat = statSync(skillDir); } catch { continue; }
if (!stat.isDirectory()) continue;
const skillFile = join(skillDir, "SKILL.md");
if (!existsSync(skillFile)) {
// Not a skill — silently skip (a directory without SKILL.md is
// either unrelated content or a partially-set-up skill the
// operator hasn't finished).
continue;
}
let content: string;
try {
content = readFileSync(skillFile, "utf-8");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
rejected.push({ path: skillDir, reason: `SKILL.md read error: ${msg}` });
continue;
}
const parsed = parseSkillFrontmatter(content);
if (!parsed.ok) {
rejected.push({ path: skillDir, reason: parsed.reason });
continue;
}
const id = parsed.frontmatter.name;
if (seenIds.has(id)) {
// Earlier root in the precedence list already provided this
// id; later occurrences are shadowed by the most-specific-wins
// rule encoded in listScanRoots ordering.
continue;
}
seenIds.add(id);
skills.push({ id, path: skillDir });
}
}
return { skills, rejected };
}
/** Build the precedence-ordered list of scan roots for a runtime.
* Earlier entries win on collision (most-specific-wins): rig-bundled
* at cwd > spec-install-dir > user libraries. Within user libraries,
* the runtime-specific dir is preferred over the shared
* ~/.openrig/skills/ pool so an operator who explicitly installed a
* Claude-only or Codex-only version takes precedence over the
* cross-runtime one. */
function listScanRoots(paths: SkillDiscoveryPaths): string[] {
const { runtime, homedir, cwd, specInstallDir } = paths;
const runtimeDir = runtime === "claude-code" ? ".claude" : ".agents";
const roots: string[] = [];
// 1. Rig-bundled at cwd (most-specific; ships with the rig source).
roots.push(join(cwd, runtimeDir, "skills"));
// 2. Spec-install-dir bundled (bundled-with-rig but installed at a
// separate path; e.g., from `rig up <bundle>` extraction).
if (specInstallDir) roots.push(join(specInstallDir, "skills"));
// 3. Runtime-specific user library (Claude-only or Codex-only
// operator install).
roots.push(join(homedir, runtimeDir, "skills"));
// 4. Shared user-spec library (cross-runtime operator install via
// `rig specs add`).
roots.push(join(homedir, ".openrig", "skills"));
return roots;
}
@@ -307,3 +307,139 @@ describe("Profile resolver + precedence engine", () => {
}
});
});
describe("V0.3.0 daemon-skill-discovery — filesystem-discovered skills join the resource pool", () => {
// Helper to set up a tmp homedir + cwd with skill folders for the
// resolver-integration tests. These use real fs because the resolver
// calls into discoverSkillsForRuntime synchronously, and a stub layer
// would only test the wiring trivially. mkdtemp / rmSync keep each
// case isolated.
const fs = require("node:fs") as typeof import("node:fs");
const path = require("node:path") as typeof import("node:path");
const os = require("node:os") as typeof import("node:os");
let tmpRoot: string;
let homedir: string;
let cwd: string;
function writeSkill(dir: string, name: string, description: string): void {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\nBody.\n`, "utf-8");
}
function withFsFixture<T>(fn: () => T): T {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "profile-resolver-disc-"));
homedir = path.join(tmpRoot, "home");
cwd = path.join(tmpRoot, "rig-cwd");
fs.mkdirSync(homedir, { recursive: true });
fs.mkdirSync(cwd, { recursive: true });
try { return fn(); } finally { fs.rmSync(tmpRoot, { recursive: true, force: true }); }
}
it("accepts a profile.uses.skills entry that resolves only via the discovered ~/.claude/skills/ path", () => {
withFsFixture(() => {
writeSkill(path.join(homedir, ".claude/skills/openrig-architect"), "openrig-architect", "Architect rigs");
const baseSpec = makeSpec({
// Note: NO `openrig-architect` in resources.skills; the only way
// for the profile to resolve it is via filesystem discovery.
resources: { skills: [], guidance: [], subagents: [], hooks: [], runtimeResources: [] },
profiles: {
default: { uses: { skills: ["openrig-architect"], guidance: [], subagents: [], hooks: [], runtimeResources: [] } },
},
});
const ctx = makeCtx({
baseSpec: makeResolved(baseSpec),
member: makeMember({ cwd }),
homedir,
});
const result = resolveNodeConfig(ctx);
expect(result.ok).toBe(true);
if (result.ok) {
const found = result.config.selectedResources.skills.find((s) => s.effectiveId === "openrig-architect");
expect(found).toBeDefined();
expect(found!.sourcePath).toBe(path.join(homedir, ".claude/skills/openrig-architect"));
}
});
});
it("accepts a profile.uses.skills entry resolving via a rig-bundled <cwd>/.claude/skills/<name>/", () => {
withFsFixture(() => {
writeSkill(path.join(cwd, ".claude/skills/web-design-guidelines"), "web-design-guidelines", "Web design checks");
const baseSpec = makeSpec({
resources: { skills: [], guidance: [], subagents: [], hooks: [], runtimeResources: [] },
profiles: {
default: { uses: { skills: ["web-design-guidelines"], guidance: [], subagents: [], hooks: [], runtimeResources: [] } },
},
});
const ctx = makeCtx({
baseSpec: makeResolved(baseSpec),
member: makeMember({ cwd }),
homedir,
});
const result = resolveNodeConfig(ctx);
expect(result.ok).toBe(true);
});
});
it("rig-local resources.skills wins over a same-id discovered skill (most-specific-wins precedence)", () => {
withFsFixture(() => {
writeSkill(path.join(homedir, ".claude/skills/skill-a"), "skill-a", "Discovered version");
// makeSpec already declares { id: 'skill-a', path: 'skills/a' } in
// resources.skills; the rig-local version should win.
const ctx = makeCtx({
baseSpec: makeResolved(makeSpec()),
member: makeMember({ cwd }),
homedir,
});
const result = resolveNodeConfig(ctx);
expect(result.ok).toBe(true);
if (result.ok) {
const skillA = result.config.selectedResources.skills.find((s) => s.effectiveId === "skill-a");
expect(skillA).toBeDefined();
// rig-local sourcePath (the agent spec's sourcePath), NOT the
// homedir-discovered SKILL.md directory.
expect(skillA!.sourcePath).toBe("/agents/test");
}
});
});
it("scans the codex .agents/skills/ tree when the runtime is codex", () => {
withFsFixture(() => {
writeSkill(path.join(homedir, ".agents/skills/openrig-architect"), "openrig-architect", "Architect rigs");
const baseSpec = makeSpec({
resources: { skills: [], guidance: [], subagents: [], hooks: [], runtimeResources: [] },
profiles: {
default: { uses: { skills: ["openrig-architect"], guidance: [], subagents: [], hooks: [], runtimeResources: [] } },
},
});
const ctx = makeCtx({
baseSpec: makeResolved(baseSpec),
member: makeMember({ cwd, runtime: "codex" }),
homedir,
});
const result = resolveNodeConfig(ctx);
expect(result.ok).toBe(true);
});
});
it("preserves the existing 'skill not found' error when a profile references a skill that exists at neither the rig-local nor the discovered paths", () => {
withFsFixture(() => {
const baseSpec = makeSpec({
resources: { skills: [], guidance: [], subagents: [], hooks: [], runtimeResources: [] },
profiles: {
default: { uses: { skills: ["nope-not-anywhere"], guidance: [], subagents: [], hooks: [], runtimeResources: [] } },
},
});
const ctx = makeCtx({
baseSpec: makeResolved(baseSpec),
member: makeMember({ cwd }),
homedir,
});
const result = resolveNodeConfig(ctx);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors.some((e) => e.includes("nope-not-anywhere"))).toBe(true);
}
});
});
});
@@ -0,0 +1,230 @@
// V0.3.0 daemon-skill-discovery — TDD-first contract for the skill
// discovery module that gives the profile resolver a runtime-truth
// view of skills present at user-library / rig-bundled paths.
//
// Skill discovery scans 5 path classes per the effective spec
// (status.md 2026-05-10):
// 1. ~/.openrig/skills/ (per-runtime user-spec library)
// 2. ~/.claude/skills/ (Claude-runtime user library)
// 3. ~/.agents/skills/ (Codex-runtime user library)
// 4. <cwd>/.claude/skills/<name>/ + <cwd>/.agents/skills/<name>/ (rig-bundled at cwd)
// 5. <spec-install-dir>/skills/<name>/ (rig-spec install dir)
//
// Per-runtime filtering: claude-code targets Claude paths;
// codex targets Codex paths; ~/.openrig/skills/ is shared.
//
// Per-skill structural validation: parse SKILL.md frontmatter; require
// `name` + `description` fields; reject the rest as "not actually a
// skill that Claude Code or Codex will load."
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
discoverSkillsForRuntime,
parseSkillFrontmatter,
type SkillDiscoveryPaths,
} from "../src/domain/skill-discovery.js";
let tmpRoot: string;
let homedir: string;
let cwd: string;
let specInstallDir: string;
function writeSkill(dir: string, frontmatter: Record<string, string>, body: string = "Body content."): void {
mkdirSync(dir, { recursive: true });
const fm = Object.entries(frontmatter)
.map(([k, v]) => `${k}: ${v}`)
.join("\n");
const content = `---\n${fm}\n---\n\n${body}\n`;
writeFileSync(join(dir, "SKILL.md"), content, "utf-8");
}
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "skill-discovery-"));
homedir = join(tmpRoot, "home");
cwd = join(tmpRoot, "rig-cwd");
specInstallDir = join(tmpRoot, "spec-install");
mkdirSync(homedir, { recursive: true });
mkdirSync(cwd, { recursive: true });
mkdirSync(specInstallDir, { recursive: true });
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
function pathsFor(runtime: "claude-code" | "codex"): SkillDiscoveryPaths {
return { runtime, homedir, cwd, specInstallDir };
}
describe("parseSkillFrontmatter — structural validation", () => {
it("accepts a SKILL.md with name + description + body and returns the parsed frontmatter", () => {
const content = "---\nname: my-skill\ndescription: Does a thing.\n---\n\nBody.\n";
const result = parseSkillFrontmatter(content);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.frontmatter.name).toBe("my-skill");
expect(result.frontmatter.description).toBe("Does a thing.");
}
});
it("rejects a file with no frontmatter as structurally invalid", () => {
const content = "Just a regular markdown file.\n\nNo frontmatter at all.\n";
const result = parseSkillFrontmatter(content);
expect(result.ok).toBe(false);
});
it("rejects a SKILL.md missing the `name` field", () => {
const content = "---\ndescription: Does a thing.\n---\n\nBody.\n";
const result = parseSkillFrontmatter(content);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/name/);
});
it("rejects a SKILL.md missing the `description` field", () => {
const content = "---\nname: my-skill\n---\n\nBody.\n";
const result = parseSkillFrontmatter(content);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/description/);
});
it("rejects a SKILL.md with empty body (the runtime would have nothing to load)", () => {
const content = "---\nname: my-skill\ndescription: Does a thing.\n---\n";
const result = parseSkillFrontmatter(content);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/body/i);
});
it("rejects a malformed YAML frontmatter block as structurally invalid", () => {
const content = "---\nname: [broken\ndescription: Does a thing.\n---\n\nBody.\n";
const result = parseSkillFrontmatter(content);
expect(result.ok).toBe(false);
});
});
describe("discoverSkillsForRuntime — Claude-runtime path scanning", () => {
it("discovers a skill from ~/.claude/skills/<name>/", () => {
writeSkill(join(homedir, ".claude/skills/openrig-architect"), {
name: "openrig-architect",
description: "Architect rigs",
});
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const ids = result.skills.map((s) => s.id);
expect(ids).toContain("openrig-architect");
});
it("discovers a skill from ~/.openrig/skills/<name>/ (shared user-spec library)", () => {
writeSkill(join(homedir, ".openrig/skills/alignment-trace"), {
name: "alignment-trace",
description: "Trace alignment",
});
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const ids = result.skills.map((s) => s.id);
expect(ids).toContain("alignment-trace");
});
it("discovers a skill from rig-bundled <cwd>/.claude/skills/<name>/", () => {
writeSkill(join(cwd, ".claude/skills/web-design-guidelines"), {
name: "web-design-guidelines",
description: "Web design checks",
});
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const ids = result.skills.map((s) => s.id);
expect(ids).toContain("web-design-guidelines");
});
it("discovers a skill from <spec-install-dir>/skills/<name>/", () => {
writeSkill(join(specInstallDir, "skills/remotion-best-practices"), {
name: "remotion-best-practices",
description: "Remotion patterns",
});
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const ids = result.skills.map((s) => s.id);
expect(ids).toContain("remotion-best-practices");
});
it("does NOT scan ~/.agents/skills/ for the claude-code runtime", () => {
writeSkill(join(homedir, ".agents/skills/codex-only"), {
name: "codex-only",
description: "Codex thing",
});
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const ids = result.skills.map((s) => s.id);
expect(ids).not.toContain("codex-only");
});
});
describe("discoverSkillsForRuntime — Codex-runtime path scanning", () => {
it("discovers a skill from ~/.agents/skills/<name>/", () => {
writeSkill(join(homedir, ".agents/skills/openrig-architect"), {
name: "openrig-architect",
description: "Architect rigs",
});
const result = discoverSkillsForRuntime(pathsFor("codex"));
const ids = result.skills.map((s) => s.id);
expect(ids).toContain("openrig-architect");
});
it("discovers a skill from rig-bundled <cwd>/.agents/skills/<name>/", () => {
writeSkill(join(cwd, ".agents/skills/alignment-trace"), {
name: "alignment-trace",
description: "Trace alignment",
});
const result = discoverSkillsForRuntime(pathsFor("codex"));
const ids = result.skills.map((s) => s.id);
expect(ids).toContain("alignment-trace");
});
it("does NOT scan ~/.claude/skills/ for the codex runtime", () => {
writeSkill(join(homedir, ".claude/skills/claude-only"), {
name: "claude-only",
description: "Claude thing",
});
const result = discoverSkillsForRuntime(pathsFor("codex"));
const ids = result.skills.map((s) => s.id);
expect(ids).not.toContain("claude-only");
});
});
describe("discoverSkillsForRuntime — structural rejection at scan time", () => {
it("skips a directory with no SKILL.md (not a skill)", () => {
mkdirSync(join(homedir, ".claude/skills/junk-dir"), { recursive: true });
writeFileSync(join(homedir, ".claude/skills/junk-dir/README.md"), "Not a skill.", "utf-8");
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const ids = result.skills.map((s) => s.id);
expect(ids).not.toContain("junk-dir");
});
it("skips a SKILL.md that fails frontmatter validation and surfaces a structured rejection", () => {
const dir = join(homedir, ".claude/skills/broken-skill");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), "no frontmatter\n", "utf-8");
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const ids = result.skills.map((s) => s.id);
expect(ids).not.toContain("broken-skill");
expect(result.rejected.some((r) => r.path.includes("broken-skill"))).toBe(true);
});
});
describe("discoverSkillsForRuntime — SkillResource shape", () => {
it("returns each discovered skill as { id, path } with id from frontmatter and path pointing at the skill directory", () => {
writeSkill(join(homedir, ".claude/skills/openrig-architect"), {
name: "openrig-architect",
description: "Architect rigs",
});
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
const found = result.skills.find((s) => s.id === "openrig-architect");
expect(found).toBeDefined();
expect(found!.path).toBe(join(homedir, ".claude/skills/openrig-architect"));
});
it("is robust to missing top-level scan directories (returns an empty list, not a throw)", () => {
// homedir + cwd + specInstallDir all exist but the .claude/skills /
// .agents/skills / .openrig/skills / skills subdirs don't.
const result = discoverSkillsForRuntime(pathsFor("claude-code"));
expect(result.skills).toEqual([]);
expect(result.rejected).toEqual([]);
});
});