Skip to content

Commit 84605bb

Browse files
sweetmantechclaude
andcommitted
refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each helper now lives in its own file with its own focused test suite: - lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts - 3 new unit tests (prefer SKILL.md, fall back to skill.md, null when neither exists) - lib/skills/getGlobalSkillsDirectory.ts — was inlined in getSandboxSkillDirectories.ts - 2 new unit tests (standard path, trailing-slash tolerance) discoverSkills now imports findSkillFile. getSandboxSkillDirectories imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories test loses its inline getGlobalSkillsDirectory cases (those moved to the dedicated test file). Full suite passes; lint clean; production build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 930ffc2 commit 84605bb

7 files changed

Lines changed: 101 additions & 50 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import{describe,it,expect,vi,beforeEach}from"vitest";
2+
import{findSkillFile}from"@/lib/skills/findSkillFile";
3+
4+
beforeEach(()=>vi.clearAllMocks());
5+
6+
functionmakeSandbox(existing: string[]){
7+
constset=newSet(existing);
8+
return{
9+
access: vi.fn(async(p: string)=>{
10+
if(!set.has(p))thrownewError(`ENOENT: ${p}`);
11+
}),
12+
};
13+
}
14+
15+
describe("findSkillFile",()=>{
16+
it("prefers uppercase SKILL.md when both casings exist",async()=>{
17+
constsb=makeSandbox(["/skills/foo/SKILL.md","/skills/foo/skill.md"]);
18+
constresult=awaitfindSkillFile(sbasnever,"/skills/foo");
19+
expect(result).toBe("/skills/foo/SKILL.md");
20+
expect(sb.access).toHaveBeenCalledWith("/skills/foo/SKILL.md");
21+
});
22+
23+
it("falls back to lowercase skill.md when SKILL.md is missing",async()=>{
24+
constsb=makeSandbox(["/skills/foo/skill.md"]);
25+
constresult=awaitfindSkillFile(sbasnever,"/skills/foo");
26+
expect(result).toBe("/skills/foo/skill.md");
27+
});
28+
29+
it("returns null when neither casing exists",async()=>{
30+
constsb=makeSandbox([]);
31+
constresult=awaitfindSkillFile(sbasnever,"/skills/foo");
32+
expect(result).toBeNull();
33+
});
34+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import{describe,it,expect}from"vitest";
2+
import{getGlobalSkillsDirectory}from"@/lib/skills/getGlobalSkillsDirectory";
3+
4+
describe("getGlobalSkillsDirectory",()=>{
5+
it("returns <home>/.agents/skills",()=>{
6+
expect(getGlobalSkillsDirectory("/root")).toBe("/root/.agents/skills");
7+
expect(getGlobalSkillsDirectory("/home/vercel-sandbox")).toBe(
8+
"/home/vercel-sandbox/.agents/skills",
9+
);
10+
});
11+
12+
it("handles trailing slash on input",()=>{
13+
expect(getGlobalSkillsDirectory("/root/")).toBe("/root/.agents/skills");
14+
});
15+
});

‎lib/skills/__tests__/getSandboxSkillDirectories.test.ts‎

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import{describe,it,expect,vi,beforeEach}from"vitest";
2-
import{
3-
getGlobalSkillsDirectory,
4-
getSandboxSkillDirectories,
5-
}from"@/lib/skills/getSandboxSkillDirectories";
2+
import{getSandboxSkillDirectories}from"@/lib/skills/getSandboxSkillDirectories";
63
import{resolveSandboxHomeDirectory}from"@/lib/sandbox/resolveSandboxHomeDirectory";
74

85
vi.mock("@/lib/sandbox/resolveSandboxHomeDirectory",()=>({
@@ -11,15 +8,6 @@ vi.mock("@/lib/sandbox/resolveSandboxHomeDirectory", () => ({
118

129
beforeEach(()=>vi.clearAllMocks());
1310

14-
describe("getGlobalSkillsDirectory",()=>{
15-
it("returns <home>/.agents/skills",()=>{
16-
expect(getGlobalSkillsDirectory("/root")).toBe("/root/.agents/skills");
17-
expect(getGlobalSkillsDirectory("/home/vercel-sandbox")).toBe(
18-
"/home/vercel-sandbox/.agents/skills",
19-
);
20-
});
21-
});
22-
2311
describe("getSandboxSkillDirectories",()=>{
2412
it("returns just the global skill dir under the resolved $HOME",async()=>{
2513
vi.mocked(resolveSandboxHomeDirectory).mockResolvedValue("/home/vercel-sandbox");

‎lib/skills/discoverSkills.ts‎

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import*aspathfrom"path";
22
importtype{Sandbox}from"@/lib/sandbox/interface";
3+
import{findSkillFile}from"@/lib/skills/findSkillFile";
34
import{parseSkillFrontmatter}from"@/lib/skills/parseSkillFrontmatter";
45
import{frontmatterToOptions,typeSkillMetadata}from"@/lib/skills/skillTypes";
56

@@ -9,29 +10,6 @@ import { frontmatterToOptions, type SkillMetadata } from "@/lib/skills/skillType
910
*/
1011
constBUILTIN_COMMANDS=["model","resume","new"];
1112

12-
/**
13-
* Locate the SKILL.md file inside a candidate skill directory. Prefers
14-
* uppercase `SKILL.md`, falls back to lowercase `skill.md`, returns null
15-
* when neither exists.
16-
*/
17-
asyncfunctionfindSkillFile(sandbox: Sandbox,skillDir: string): Promise<string|null>{
18-
constuppercase=path.join(skillDir,"SKILL.md");
19-
constlowercase=path.join(skillDir,"skill.md");
20-
21-
try{
22-
awaitsandbox.access(uppercase);
23-
returnuppercase;
24-
}catch{
25-
// try lowercase
26-
}
27-
try{
28-
awaitsandbox.access(lowercase);
29-
returnlowercase;
30-
}catch{
31-
returnnull;
32-
}
33-
}
34-
3513
/**
3614
* Scan a list of directories for skills. Each directory is expected to
3715
* contain one subdirectory per skill, with a SKILL.md (or skill.md)

‎lib/skills/findSkillFile.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import*aspathfrom"path";
2+
importtype{Sandbox}from"@/lib/sandbox/interface";
3+
4+
/**
5+
* Locate the SKILL.md file inside a candidate skill directory. Prefers
6+
* uppercase `SKILL.md` (the project convention) but falls back to
7+
* lowercase `skill.md` for skills that ship the lowercase name. Returns
8+
* `null` when neither file exists so callers can skip the entry.
9+
*
10+
* Probes via `sandbox.access` (which throws on missing) rather than
11+
* `readdir` so we don't pay the cost of listing a directory whose
12+
* contents we don't otherwise need.
13+
*
14+
* @param sandbox - Connected sandbox handle.
15+
* @param skillDir - Absolute path to the candidate skill directory.
16+
*/
17+
exportasyncfunctionfindSkillFile(sandbox: Sandbox,skillDir: string): Promise<string|null>{
18+
constuppercase=path.join(skillDir,"SKILL.md");
19+
constlowercase=path.join(skillDir,"skill.md");
20+
21+
try{
22+
awaitsandbox.access(uppercase);
23+
returnuppercase;
24+
}catch{
25+
// try lowercase
26+
}
27+
try{
28+
awaitsandbox.access(lowercase);
29+
returnlowercase;
30+
}catch{
31+
returnnull;
32+
}
33+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import*aspathfrom"path";
2+
3+
/**
4+
* Resolve the absolute path to the global skills directory under a
5+
* given `$HOME`. This is where `installSessionGlobalSkills` lays down
6+
* skills at sandbox provisioning time via `npx skills add ... -g`
7+
* (today: `recoup-api`, `artist-workspace`).
8+
*
9+
* @param homeDirectory - The sandbox's resolved $HOME (e.g.
10+
* `/home/vercel-sandbox`, or `/root` on the open-agents base image).
11+
*/
12+
exportfunctiongetGlobalSkillsDirectory(homeDirectory: string): string{
13+
returnpath.posix.join(homeDirectory,".agents","skills");
14+
}

‎lib/skills/getSandboxSkillDirectories.ts‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,12 @@
1-
import*aspathfrom"path";
21
importtype{Sandbox}from"@/lib/sandbox/interface";
32
import{resolveSandboxHomeDirectory}from"@/lib/sandbox/resolveSandboxHomeDirectory";
4-
5-
/**
6-
* Global skill directory under `$HOME`. This is where
7-
* `installSessionGlobalSkills` lays down skills at sandbox provisioning
8-
* time via `npx skills add ... -g`. Per project convention all skills
9-
* are provisioned globally — org repos do not bundle their own skill
10-
* directories, so there's nothing project-level to scan.
11-
*/
12-
exportfunctiongetGlobalSkillsDirectory(homeDirectory: string): string{
13-
returnpath.posix.join(homeDirectory,".agents","skills");
14-
}
3+
import{getGlobalSkillsDirectory}from"@/lib/skills/getGlobalSkillsDirectory";
154

165
/**
176
* Resolve the directory list to scan when discovering skills for a
187
* sandbox. Currently just one path — `${HOME}/.agents/skills/` —
19-
* because skills are always provisioned globally at sandbox startup
20-
* rather than bundled into the cloned repo.
8+
* because all skills are provisioned globally at sandbox startup via
9+
* `installSessionGlobalSkills` rather than bundled into the cloned repo.
2110
*
2211
* @param sandbox - Connected sandbox handle.
2312
*/

0 commit comments

Comments
 (0)