mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
1644e742ac
commit
1482aef986
4 files changed
+604
-9
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user