Learn Claude Code
Back to Skills

SubagentsSkills

s04 (288 LOC) → s05 (321 LOC)

LOC Delta

+33lines

New Tools

1

load_skill
New Classes

1

SkillLoader
New Functions

2

parseFrontmattercollectSkillFiles

Subagents

Clean Context Per Subtask

288 LOC

5 tools: bash, read_file, write_file, edit_file, task

planning

Skills

Load on Demand

321 LOC

5 tools: bash, read_file, write_file, edit_file, load_skill

planning

Source Code Diff

s04 (s04_subagent.ts) -> s05 (s05_skill_loading.ts)
11#!/usr/bin/env node
22/**
3- * s04_subagent.ts - Subagents
3+ * s05_skill_loading.ts - Skills
44 *
5- * Spawn a child agent with fresh messages=[].
6- * The child shares the filesystem, but returns only a short summary.
5+ * Two-layer skill injection:
6+ * 1. Keep lightweight skill metadata in the system prompt.
7+ * 2. Load the full SKILL.md body only when the model asks for it.
78 */
89
910import { spawnSync } from "node:child_process";
10-import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
1112import { resolve } from "node:path";
1213import process from "node:process";
1314import { createInterface } from "node:readline/promises";
1415import type Anthropic from "@anthropic-ai/sdk";
1516import "dotenv/config";
1617import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
1718
18-type BaseToolName = "bash" | "read_file" | "write_file" | "edit_file";
19-type ParentToolName = BaseToolName | "task";
19+type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "load_skill";
2020
2121type ToolUseBlock = {
2222 id: string;
2323 type: "tool_use";
24- name: ParentToolName;
24+ name: ToolName;
2525 input: Record<string, unknown>;
2626};
2727
2828type TextBlock = {
2929 type: "text";
3030 text: string;
3131};
3232
3333type ToolResultBlock = {
3434 type: "tool_result";
3535 tool_use_id: string;
3636 content: string;
3737};
3838
3939type MessageContent = string | Array<ToolUseBlock | TextBlock | ToolResultBlock>;
4040
4141type Message = {
4242 role: "user" | "assistant";
4343 content: MessageContent;
4444};
4545
46+type SkillRecord = {
47+ meta: Record<string, string>;
48+ body: string;
49+ path: string;
50+};
51+
4652const WORKDIR = process.cwd();
4753const MODEL = resolveModel();
54+const SKILLS_DIR = resolve(WORKDIR, "..", "skills");
4855const client = createAnthropicClient();
4956
50-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use the task tool to delegate exploration or subtasks.`);
51-const SUBAGENT_SYSTEM = buildSystemPrompt(`You are a coding subagent at ${WORKDIR}. Complete the given task, then summarize your findings.`);
52-
5357function safePath(relativePath: string): string {
5458 const filePath = resolve(WORKDIR, relativePath);
5559 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
5660 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) {
5761 throw new Error(`Path escapes workspace: ${relativePath}`);
5862 }
5963 return filePath;
6064}
6165
6266function runBash(command: string): string {
6367 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
6468 if (dangerous.some((item) => command.includes(item))) {
6569 return "Error: Dangerous command blocked";
6670 }
6771
6872 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
6973 const args = process.platform === "win32"
7074 ? ["/d", "/s", "/c", command]
7175 : ["-lc", command];
7276
7377 const result = spawnSync(shell, args, {
7478 cwd: WORKDIR,
7579 encoding: "utf8",
7680 timeout: 120_000,
7781 });
7882
7983 if (result.error?.name === "TimeoutError") {
8084 return "Error: Timeout (120s)";
8185 }
8286
8387 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
8488 return output.slice(0, 50_000) || "(no output)";
8589}
8690
8791function runRead(path: string, limit?: number): string {
8892 try {
8993 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
9094 if (limit && limit < lines.length) {
9195 lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
9296 }
9397 return lines.join("\n").slice(0, 50_000);
9498 } catch (error) {
9599 return `Error: ${error instanceof Error ? error.message : String(error)}`;
96100 }
97101}
98102
99103function runWrite(path: string, content: string): string {
100104 try {
101105 const filePath = safePath(path);
102106 mkdirSync(resolve(filePath, ".."), { recursive: true });
103107 writeFileSync(filePath, content, "utf8");
104108 return `Wrote ${content.length} bytes`;
105109 } catch (error) {
106110 return `Error: ${error instanceof Error ? error.message : String(error)}`;
107111 }
108112}
109113
110114function runEdit(path: string, oldText: string, newText: string): string {
111115 try {
112116 const filePath = safePath(path);
113117 const content = readFileSync(filePath, "utf8");
114118 if (!content.includes(oldText)) {
115119 return `Error: Text not found in ${path}`;
116120 }
117121 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
118122 return `Edited ${path}`;
119123 } catch (error) {
120124 return `Error: ${error instanceof Error ? error.message : String(error)}`;
121125 }
122126}
123127
124-const TOOL_HANDLERS: Record<BaseToolName, (input: Record<string, unknown>) => string> = {
128+function parseFrontmatter(text: string): { meta: Record<string, string>; body: string } {
129+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/m.exec(text);
130+ if (!match) {
131+ return { meta: {}, body: text.trim() };
132+ }
133+
134+ const meta: Record<string, string> = {};
135+ for (const line of match[1].split(/\r?\n/)) {
136+ const separator = line.indexOf(":");
137+ if (separator < 0) continue;
138+ const key = line.slice(0, separator).trim();
139+ const value = line.slice(separator + 1).trim();
140+ if (key) meta[key] = value;
141+ }
142+
143+ return { meta, body: match[2].trim() };
144+}
145+
146+function collectSkillFiles(dir: string): string[] {
147+ if (!existsSync(dir)) {
148+ return [];
149+ }
150+
151+ const files: string[] = [];
152+ const stack = [dir];
153+
154+ while (stack.length > 0) {
155+ const current = stack.pop();
156+ if (!current) continue;
157+
158+ for (const entry of readdirSync(current)) {
159+ const entryPath = resolve(current, entry);
160+ const stats = statSync(entryPath);
161+ if (stats.isDirectory()) {
162+ stack.push(entryPath);
163+ continue;
164+ }
165+
166+ if (stats.isFile() && entry === "SKILL.md") {
167+ files.push(entryPath);
168+ }
169+ }
170+ }
171+
172+ return files.sort((a, b) => a.localeCompare(b));
173+}
174+
175+class SkillLoader {
176+ skills: Record<string, SkillRecord> = {};
177+
178+ constructor(private skillsDir: string) {
179+ this.loadAll();
180+ }
181+
182+ private loadAll() {
183+ for (const filePath of collectSkillFiles(this.skillsDir)) {
184+ const text = readFileSync(filePath, "utf8");
185+ const { meta, body } = parseFrontmatter(text);
186+ const normalized = filePath.replace(/\\/g, "/");
187+ const fallbackName = normalized.split("/").slice(-2, -1)[0] ?? "unknown";
188+ const name = meta.name || fallbackName;
189+ this.skills[name] = { meta, body, path: filePath };
190+ }
191+ }
192+
193+ getDescriptions(): string {
194+ const entries = Object.entries(this.skills);
195+ if (entries.length === 0) {
196+ return "(no skills available)";
197+ }
198+
199+ return entries
200+ .map(([name, skill]) => {
201+ const desc = skill.meta.description || "No description";
202+ const tags = skill.meta.tags ? ` [${skill.meta.tags}]` : "";
203+ return ` - ${name}: ${desc}${tags}`;
204+ })
205+ .join("\n");
206+ }
207+
208+ getContent(name: string): string {
209+ const skill = this.skills[name];
210+ if (!skill) {
211+ const names = Object.keys(this.skills).join(", ");
212+ return `Error: Unknown skill '${name}'. Available: ${names}`;
213+ }
214+ return `<skill name="${name}">\n${skill.body}\n</skill>`;
215+ }
216+}
217+
218+const skillLoader = new SkillLoader(SKILLS_DIR);
219+
220+const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}.
221+Use load_skill to access specialized knowledge before tackling unfamiliar topics.
222+
223+Skills available:
224+${skillLoader.getDescriptions()}`);
225+
226+const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = {
125227 bash: (input) => runBash(String(input.command ?? "")),
126228 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
127229 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
128230 edit_file: (input) =>
129231 runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
232+ load_skill: (input) => skillLoader.getContent(String(input.name ?? "")),
130233};
131234
132-const CHILD_TOOLS = [
235+const TOOLS = [
133236 {
134237 name: "bash",
135238 description: shellToolDescription(),
136239 input_schema: {
137240 type: "object",
138241 properties: { command: { type: "string" } },
139242 required: ["command"],
140243 },
141244 },
142245 {
143246 name: "read_file",
144247 description: "Read file contents.",
145248 input_schema: {
146249 type: "object",
147250 properties: { path: { type: "string" }, limit: { type: "integer" } },
148251 required: ["path"],
149252 },
150253 },
151254 {
152255 name: "write_file",
153256 description: "Write content to file.",
154257 input_schema: {
155258 type: "object",
156259 properties: { path: { type: "string" }, content: { type: "string" } },
157260 required: ["path", "content"],
158261 },
159262 },
160263 {
161264 name: "edit_file",
162265 description: "Replace exact text in file.",
163266 input_schema: {
164267 type: "object",
165268 properties: {
166269 path: { type: "string" },
167270 old_text: { type: "string" },
168271 new_text: { type: "string" },
169272 },
170273 required: ["path", "old_text", "new_text"],
171274 },
172275 },
173-];
174-
175-const PARENT_TOOLS = [
176- ...CHILD_TOOLS,
177276 {
178- name: "task",
179- description: "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.",
277+ name: "load_skill",
278+ description: "Load specialized knowledge by name.",
180279 input_schema: {
181280 type: "object",
182281 properties: {
183- prompt: { type: "string" },
184- description: { type: "string" },
282+ name: { type: "string", description: "Skill name to load" },
185283 },
186- required: ["prompt"],
284+ required: ["name"],
187285 },
188286 },
189287];
190288
191289function assistantText(content: Array<ToolUseBlock | TextBlock | ToolResultBlock>) {
192290 return content
193291 .filter((block): block is TextBlock => block.type === "text")
194292 .map((block) => block.text)
195293 .join("\n");
196294}
197295
198-async function runSubagent(prompt: string): Promise<string> {
199- const subMessages: Message[] = [{ role: "user", content: prompt }];
200- let response: Anthropic.Messages.Message | null = null;
201-
202- for (let attempt = 0; attempt < 30; attempt += 1) {
203- response = await client.messages.create({
204- model: MODEL,
205- system: SUBAGENT_SYSTEM,
206- messages: subMessages as Anthropic.Messages.MessageParam[],
207- tools: CHILD_TOOLS as Anthropic.Messages.Tool[],
208- max_tokens: 8000,
209- });
210-
211- subMessages.push({
212- role: "assistant",
213- content: response.content as Array<ToolUseBlock | TextBlock>,
214- });
215-
216- if (response.stop_reason !== "tool_use") {
217- break;
218- }
219-
220- const results: ToolResultBlock[] = [];
221- for (const block of response.content) {
222- if (block.type !== "tool_use" || block.name === "task") continue;
223- const handler = TOOL_HANDLERS[block.name as BaseToolName];
224- const output = handler
225- ? handler(block.input as Record<string, unknown>)
226- : `Unknown tool: ${block.name}`;
227- results.push({
228- type: "tool_result",
229- tool_use_id: block.id,
230- content: String(output).slice(0, 50_000),
231- });
232- }
233-
234- subMessages.push({ role: "user", content: results });
235- }
236-
237- if (!response) return "(no summary)";
238- const texts: string[] = [];
239- for (const block of response.content) {
240- if (block.type === "text") texts.push(block.text);
241- }
242- return texts.join("") || "(no summary)";
243-}
244-
245296export async function agentLoop(messages: Message[]) {
246297 while (true) {
247298 const response = await client.messages.create({
248299 model: MODEL,
249300 system: SYSTEM,
250301 messages: messages as Anthropic.Messages.MessageParam[],
251- tools: PARENT_TOOLS as Anthropic.Messages.Tool[],
302+ tools: TOOLS as Anthropic.Messages.Tool[],
252303 max_tokens: 8000,
253304 });
254305
255306 messages.push({
256307 role: "assistant",
257308 content: response.content as Array<ToolUseBlock | TextBlock>,
258309 });
259310
260311 if (response.stop_reason !== "tool_use") {
261312 return;
262313 }
263314
264315 const results: ToolResultBlock[] = [];
265316 for (const block of response.content) {
266317 if (block.type !== "tool_use") continue;
267318
268- let output: string;
269- if (block.name === "task") {
270- const input = block.input as { description?: string; prompt?: string };
271- const description = String(input.description ?? "subtask");
272- console.log(`> task (${description}): ${String(input.prompt ?? "").slice(0, 80)}`);
273- output = await runSubagent(String(input.prompt ?? ""));
274- } else {
275- const handler = TOOL_HANDLERS[block.name as BaseToolName];
276- output = handler
277- ? handler(block.input as Record<string, unknown>)
278- : `Unknown tool: ${block.name}`;
279- }
319+ const handler = TOOL_HANDLERS[block.name as ToolName];
320+ const output = handler
321+ ? handler(block.input as Record<string, unknown>)
322+ : `Unknown tool: ${block.name}`;
280323
281- console.log(` ${output.slice(0, 200)}`);
324+ console.log(`> ${block.name}: ${output.slice(0, 200)}`);
282325 results.push({
283326 type: "tool_result",
284327 tool_use_id: block.id,
285328 content: output,
286329 });
287330 }
288331
289332 messages.push({ role: "user", content: results });
290333 }
291334}
292335
293336async function main() {
294337 const rl = createInterface({
295338 input: process.stdin,
296339 output: process.stdout,
297340 });
298341
299342 const history: Message[] = [];
300343
301344 while (true) {
302345 let query = "";
303346 try {
304- query = await rl.question("\x1b[36ms04 >> \x1b[0m");
347+ query = await rl.question("\x1b[36ms05 >> \x1b[0m");
305348 } catch (error) {
306349 if (
307350 error instanceof Error &&
308351 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
309352 ) {
310353 break;
311354 }
312355 throw error;
313356 }
314357 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) {
315358 break;
316359 }
317360
318361 history.push({ role: "user", content: query });
319362 await agentLoop(history);
320363
321364 const last = history[history.length - 1]?.content;
322365 if (Array.isArray(last)) {
323366 const text = assistantText(last);
324367 if (text) console.log(text);
325368 }
326369 console.log();
327370 }
328371
329372 rl.close();
330373}
331374
332375void main();