Skip to content

Commit cd6b7cd

Browse files
committed
feat(skills): LocalLoader + registry assembly on the agent bundle
Adds src/skills/local-loader.ts to read user-authored skills, templates, and prompts from ~/.codebase/{skills,templates,prompts}/. Each file is markdown with optional YAML-ish frontmatter that supplies id / name / description / tags; filename is the default id when omitted. Includes a buildAssetRegistry factory that composes LocalLoader with PlatformLoader and exposes the resulting AssetRegistry on AgentBundle as `assets`. PlatformLoader stays disabled at the agent level for now (no stable endpoint contract yet) so the assembly is local-only on main, but the wire is in place for the platform-side endpoint to land without further refactoring on this side. 8 unit tests for LocalLoader covering full frontmatter, filename fallback, separate dirs for each kind, missing-frontmatter tolerance, non-md ignore, and bracketed-list parsing.
1 parent 4b475e0 commit cd6b7cd

5 files changed

Lines changed: 407 additions & 5 deletions

File tree

src/agent/agent.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { MemoryStore } from "../memory/store.js";
1616
import { PermissionStore } from "../permissions/store.js";
1717
import { PlanModeStore } from "../plan/store.js";
1818
import { SessionStore } from "../sessions/store.js";
19+
import type { AssetRegistry } from "../skills/loader.js";
20+
import { buildAssetRegistry } from "../skills/registry-factory.js";
1921
import { BackgroundShellStore } from "../tools/background-shell-store.js";
2022
import { FileStateCache } from "../tools/file-state-cache.js";
2123
import { buildTools } from "../tools/registry.js";
@@ -97,6 +99,13 @@ export interface AgentBundle {
9799
sessions: SessionStore;
98100
hooks: HookManager;
99101
diagnostics: DiagnosticsEngine;
102+
/**
103+
* Curated assets — skills, templates, prompts — sourced from
104+
* ~/.codebase/{skills,templates,prompts}/ and (when signed in)
105+
* codebase.foundation. Consumers read on demand; no auto-merge into
106+
* the system prompt today.
107+
*/
108+
assets: AssetRegistry;
100109
subscribe: (listener: (event: AgentEvent) => void) => () => void;
101110
/**
102111
* User-initiated prompt — fires UserPromptSubmit hooks; honors exit-code-2
@@ -155,6 +164,10 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
155164
const hooks = new HookManager();
156165
hooks.loadFrom(join(homedir(), ".codebase", "hooks.json"), join(cwd, ".codebase", "hooks.json"));
157166
const diagnostics = new DiagnosticsEngine({ cwd });
167+
// PlatformLoader is gated on a real auth session — for now we skip
168+
// it (LocalLoader still works). A future change wires it once we have
169+
// a stable endpoint contract and the user is signed in.
170+
const assets = buildAssetRegistry();
158171

159172
const glueModels = resolveGlueModels({ parentModel: model, parentApiKey: apiKey });
160173
const glue = new GlueClient({
@@ -429,6 +442,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
429442
sessions,
430443
hooks,
431444
diagnostics,
445+
assets,
432446
subscribe,
433447
submitUserPrompt,
434448
resumedFrom: resumed ? { updatedAt: resumed.updatedAt, messageCount: resumed.messages.length } : undefined,

src/skills/loader.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import type { Asset, AssetSource, PromptAsset, SkillAsset, TemplateAsset } from
66
* call, so loaders can come and go (e.g. PlatformLoader becomes
77
* available after the user runs `codebase auth login`).
88
*
9-
* Loaders today: BundledLoader (ships with the binary) and
10-
* PlatformLoader (fetches from codebase.foundation/api/cli/... when
11-
* signed in). LocalLoader (~/.codebase/skills/*.md) is queued.
12-
* Resolution order is platform > bundled, matching the "operator
13-
* overrides project" precedence the rest of the CLI uses.
9+
* Loaders today: LocalLoader (~/.codebase/{skills,templates,prompts}/)
10+
* and PlatformLoader (fetches from codebase.foundation/api/cli/... when
11+
* signed in). Resolution order is platform > local — register them in
12+
* that order, so a platform-shipped asset shadows a user-local override
13+
* with the same id by default.
1414
*/
1515
export interface AssetLoader {
1616
source: AssetSource;

src/skills/local-loader.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { LocalLoader } from "./local-loader.js";
6+
7+
describe("LocalLoader", () => {
8+
let root: string;
9+
let skillsDir: string;
10+
let templatesDir: string;
11+
let promptsDir: string;
12+
13+
beforeEach(() => {
14+
root = mkdtempSync(join(tmpdir(), "codebase-skills-"));
15+
skillsDir = join(root, "skills");
16+
templatesDir = join(root, "templates");
17+
promptsDir = join(root, "prompts");
18+
});
19+
20+
afterEach(() => {
21+
rmSync(root, { recursive: true, force: true });
22+
});
23+
24+
it("returns [] when no directories exist", async () => {
25+
const loader = new LocalLoader(root);
26+
await expect(loader.listSkills()).resolves.toEqual([]);
27+
await expect(loader.listTemplates()).resolves.toEqual([]);
28+
await expect(loader.listPrompts()).resolves.toEqual([]);
29+
});
30+
31+
it("loads a skill with full frontmatter", async () => {
32+
mkdirSync(skillsDir, { recursive: true });
33+
writeFileSync(
34+
join(skillsDir, "optimize.md"),
35+
[
36+
"---",
37+
"id: optimize",
38+
"name: Optimize hot path",
39+
"description: Refactor for performance",
40+
"tags: [perf, refactor]",
41+
'preferredModel: "claude-opus-4-7"',
42+
"---",
43+
"",
44+
"You are a performance-focused engineer. Trim allocations.",
45+
"",
46+
].join("\n"),
47+
);
48+
const loader = new LocalLoader(root);
49+
const skills = await loader.listSkills();
50+
expect(skills).toHaveLength(1);
51+
const s = skills[0];
52+
expect(s.id).toBe("optimize");
53+
expect(s.name).toBe("Optimize hot path");
54+
expect(s.description).toBe("Refactor for performance");
55+
expect(s.tags).toEqual(["perf", "refactor"]);
56+
expect(s.preferredModel).toBe("claude-opus-4-7");
57+
expect(s.systemPrompt).toContain("performance-focused");
58+
expect(s.source).toBe("user");
59+
});
60+
61+
it("falls back to filename as id when frontmatter omits it", async () => {
62+
mkdirSync(skillsDir, { recursive: true });
63+
writeFileSync(join(skillsDir, "summarize.md"), "Summarize the changes clearly.");
64+
const loader = new LocalLoader(root);
65+
const skills = await loader.listSkills();
66+
expect(skills).toHaveLength(1);
67+
expect(skills[0].id).toBe("summarize");
68+
expect(skills[0].name).toBe("summarize");
69+
expect(skills[0].systemPrompt).toBe("Summarize the changes clearly.");
70+
});
71+
72+
it("loads a template + prompt from their respective dirs", async () => {
73+
mkdirSync(templatesDir, { recursive: true });
74+
mkdirSync(promptsDir, { recursive: true });
75+
writeFileSync(
76+
join(templatesDir, "nextjs.md"),
77+
["---", "name: Next.js app", "---", "", "Scaffold a Next.js 15 project with app router."].join("\n"),
78+
);
79+
writeFileSync(
80+
join(promptsDir, "explain-diff.md"),
81+
["---", "name: Explain my diff", "---", "", "Explain the staged diff in plain English."].join("\n"),
82+
);
83+
const loader = new LocalLoader(root);
84+
const templates = await loader.listTemplates();
85+
const prompts = await loader.listPrompts();
86+
expect(templates).toHaveLength(1);
87+
expect(templates[0].kind).toBe("template");
88+
expect(templates[0].name).toBe("Next.js app");
89+
expect(prompts).toHaveLength(1);
90+
expect(prompts[0].kind).toBe("prompt");
91+
expect(prompts[0].body).toContain("staged diff");
92+
});
93+
94+
it("ignores non-md files", async () => {
95+
mkdirSync(skillsDir, { recursive: true });
96+
writeFileSync(join(skillsDir, "README"), "documentation");
97+
writeFileSync(join(skillsDir, "junk.txt"), "ignored");
98+
writeFileSync(join(skillsDir, "real.md"), "real skill body");
99+
const loader = new LocalLoader(root);
100+
const skills = await loader.listSkills();
101+
expect(skills).toHaveLength(1);
102+
expect(skills[0].id).toBe("real");
103+
});
104+
105+
it("supports a file with no frontmatter at all", async () => {
106+
mkdirSync(skillsDir, { recursive: true });
107+
writeFileSync(join(skillsDir, "raw.md"), "Just the body, nothing else.\n");
108+
const loader = new LocalLoader(root);
109+
const skills = await loader.listSkills();
110+
expect(skills).toHaveLength(1);
111+
expect(skills[0].id).toBe("raw");
112+
expect(skills[0].systemPrompt).toBe("Just the body, nothing else.\n");
113+
});
114+
115+
it("supports a file whose frontmatter is never closed (treats whole file as body)", async () => {
116+
mkdirSync(skillsDir, { recursive: true });
117+
writeFileSync(join(skillsDir, "broken.md"), "---\nid: oops\nname: missing close\n\nstill body");
118+
const loader = new LocalLoader(root);
119+
const skills = await loader.listSkills();
120+
expect(skills).toHaveLength(1);
121+
expect(skills[0].id).toBe("broken"); // falls back to filename, since no frontmatter recognized
122+
});
123+
124+
it("parses bare-string and bracketed-list values", async () => {
125+
mkdirSync(skillsDir, { recursive: true });
126+
writeFileSync(
127+
join(skillsDir, "test.md"),
128+
["---", "name: My Skill", "tags: [a, b, c]", "---", "", "body"].join("\n"),
129+
);
130+
const loader = new LocalLoader(root);
131+
const skills = await loader.listSkills();
132+
expect(skills[0].name).toBe("My Skill");
133+
expect(skills[0].tags).toEqual(["a", "b", "c"]);
134+
});
135+
});

0 commit comments

Comments
 (0)