toolfactory 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/e2e.test.ts CHANGED
@@ -7,6 +7,7 @@ import { execFileSync } from "node:child_process";
7
7
  import {
8
8
  existsSync,
9
9
  lstatSync,
10
+ mkdirSync,
10
11
  mkdtempSync,
11
12
  readdirSync,
12
13
  readFileSync,
@@ -33,7 +34,15 @@ describe.skipIf(!existsSync(repoNodeModules))(
33
34
  const dir = mkdtempSync(join(tmpdir(), "toolfactory-e2e-"));
34
35
  symlinkSync(repoNodeModules, join(dir, "node_modules"), "dir");
35
36
 
36
- const surfaces: SurfaceId[] = ["skill", "agent-plugins", "claude", "mcp", "cli", "npm"];
37
+ const surfaces: SurfaceId[] = [
38
+ "skill",
39
+ "agent-plugins",
40
+ "claude",
41
+ "mcp",
42
+ "cli",
43
+ "npm",
44
+ "openclaw-native",
45
+ ];
37
46
  const init = commands.init({
38
47
  root: dir,
39
48
  name: "hello",
@@ -53,8 +62,42 @@ describe.skipIf(!existsSync(repoNodeModules))(
53
62
  expect(echo.inputSchema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
54
63
  expect(echo.outputSchema?.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
55
64
 
56
- commands.build(dir);
65
+ const skillPath = join(dir, "skills/hello/SKILL.md");
66
+ writeFileSync(
67
+ skillPath,
68
+ readFileSync(skillPath, "utf8").replace("# hello", "# hello\n\nAuthor procedure."),
69
+ );
70
+ mkdirSync(join(dir, "skills/hello/references"));
71
+ writeFileSync(join(dir, "skills/hello/references/guide.md"), "Retained reference.\n");
72
+ const opsPath = join(dir, "src/ops.ts");
73
+ writeFileSync(
74
+ opsPath,
75
+ readFileSync(opsPath, "utf8").replace(
76
+ "Return the text you pass in.",
77
+ "Freshly rebuilt operation.",
78
+ ),
79
+ );
80
+ await commands.introspect(dir);
57
81
  await expect(commands.check(dir)).resolves.toBeDefined();
82
+ expect(commands.build(dir).result.written).toEqual([]);
83
+ const bundled = readFileSync(join(dir, "hosts/openclaw/skills/hello/SKILL.md"), "utf8");
84
+ expect(bundled).toBe(readFileSync(skillPath, "utf8"));
85
+ expect(bundled).toContain("Author procedure.");
86
+ expect(bundled).toContain("Freshly rebuilt operation.");
87
+ expect(bundled).not.toContain("Return the text you pass in.");
88
+ const packed = JSON.parse(
89
+ execFileSync("npm", ["pack", "./hosts/openclaw", "--dry-run", "--json"], {
90
+ cwd: dir,
91
+ encoding: "utf8",
92
+ }),
93
+ ) as { files: { path: string }[] }[];
94
+ expect(packed[0]?.files.map((file) => file.path)).toEqual(
95
+ expect.arrayContaining([
96
+ "openclaw.plugin.json",
97
+ "skills/hello/SKILL.md",
98
+ "skills/hello/references/guide.md",
99
+ ]),
100
+ );
58
101
 
59
102
  const opsOnDisk = JSON.parse(readFileSync(join(dir, "dev.toolfactory/ops.json"), "utf8"));
60
103
  expect(opsOnDisk.tools.map((t: { name: string }) => t.name)).toEqual(["echo"]);
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
16
16
  import type { PlannedFile, Project } from "../model.js";
17
17
  import type { Drift } from "../project/apply.js";
18
18
  import { loadProject } from "../project/load.js";
19
- import { HOST_DIR, OPENCLAW_SCAFFOLD, surface } from "../surfaces/openclaw-native.js";
19
+ import { HOST_DIR, OPENCLAW_SCAFFOLD, shipsSkills, surface } from "../surfaces/openclaw-native.js";
20
20
 
21
21
  export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
22
22
 
@@ -39,7 +39,7 @@ function overridden(project: Project): Set<string> {
39
39
  paths.add(".openclaw.compat.pluginApi");
40
40
  }
41
41
  // Shipped skills append to `files`; upstream's entries must still survive (checked below).
42
- if (openclaw?.skills?.length) paths.add(".files");
42
+ if (shipsSkills(project)) paths.add(".files");
43
43
  return paths;
44
44
  }
45
45
 
@@ -111,7 +111,7 @@ export function scaffoldDrift(project: Project): string[] {
111
111
  ? []
112
112
  : ["vitest.config.ts: upstream template changed"]),
113
113
  ];
114
- if (project.tool.openclaw?.skills?.length) {
114
+ if (shipsSkills(project)) {
115
115
  const upstream = (JSON.parse(scaffold("package.json")) as { files?: string[] }).files ?? [];
116
116
  const ours = (JSON.parse(planned(files, "package.json")) as { files?: string[] }).files ?? [];
117
117
  const lost = upstream.filter((entry) => !ours.includes(entry));
package/src/model.ts CHANGED
@@ -244,7 +244,7 @@ export const ToolConfigSchema = z
244
244
  .array(z.string())
245
245
  .optional()
246
246
  .describe(
247
- "Skill directories under `.agents/skills/` that the OpenClaw plugin ships; OpenClaw loads them through the manifest's `skills` roots.",
247
+ "Exact skill selection for the OpenClaw plugin. Omitted: the tool's own generated skill when the skill surface is selected. []: no skills. Other names resolve under `.agents/skills/`; OpenClaw loads the packaged manifest's `skills` roots.",
248
248
  ),
249
249
  dependencies: z.record(z.string(), z.string()).optional(),
250
250
  peerDependencies: z.record(z.string(), z.string()).optional(),
@@ -387,7 +387,7 @@ export interface Project {
387
387
  toolfactoryVersion: string;
388
388
  /** From the root package.json `packageManager` field; npm when absent. */
389
389
  packageManager?: PackageManager;
390
- /** Skills `openclaw.skills` declares, read once so projectors stay pure. */
390
+ /** Resolved OpenClaw skills, including the generated default, read once so projectors stay pure. */
391
391
  skills?: ProjectSkill[];
392
392
  }
393
393
 
@@ -501,7 +501,8 @@ export function check(root: string, plan: PlannedFile[], toolfactoryVersion: str
501
501
  return drift;
502
502
  }
503
503
 
504
- function render(root: string, file: PlannedFile, previous: Lock): string {
504
+ /** Read-only preview of the exact bytes apply writes, retaining authored regions and extensions. */
505
+ export function renderFile(root: string, file: PlannedFile, previous: Lock): string {
505
506
  const path = join(root, file.path);
506
507
  if (file.kind === "file") return file.content;
507
508
  if (file.kind === "region") {
@@ -589,7 +590,7 @@ export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: str
589
590
  result.written.push(file.path);
590
591
  }
591
592
  } else {
592
- const next = render(root, file, previous);
593
+ const next = renderFile(root, file, previous);
593
594
  if (existsSync(path) && readFileSync(path, "utf8") === next) {
594
595
  result.unchanged.push(file.path);
595
596
  } else {
@@ -0,0 +1,229 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { build } from "../commands.js";
6
+ import type { ToolConfig } from "../model.js";
7
+ import { HOST_DIR } from "../surfaces/openclaw-native.js";
8
+ import { check, setState } from "./apply.js";
9
+ import { loadProject } from "./load.js";
10
+ import { buildPlan } from "./plan.js";
11
+
12
+ const roots: string[] = [];
13
+ afterEach(() => {
14
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
15
+ });
16
+
17
+ function write(root: string, path: string, content: string): void {
18
+ mkdirSync(dirname(join(root, path)), { recursive: true });
19
+ writeFileSync(join(root, path), content);
20
+ }
21
+
22
+ function fixture(overrides: Partial<ToolConfig> = {}): string {
23
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-skills-"));
24
+ roots.push(root);
25
+ write(
26
+ root,
27
+ "plugin.json",
28
+ JSON.stringify({ name: "probe", version: "0.1.0", description: "Probe" }),
29
+ );
30
+ write(
31
+ root,
32
+ "dev.toolfactory/tool.json",
33
+ JSON.stringify({
34
+ schemaVersion: 1,
35
+ identity: "plugin.json",
36
+ binding: "typescript",
37
+ surfaces: ["skill", "cli", "openclaw-native"],
38
+ ...overrides,
39
+ }),
40
+ );
41
+ write(
42
+ root,
43
+ "dev.toolfactory/ops.json",
44
+ JSON.stringify({
45
+ tools: [
46
+ {
47
+ name: "echo",
48
+ description: "Current operation",
49
+ inputSchema: { type: "object" },
50
+ },
51
+ ],
52
+ }),
53
+ );
54
+ return root;
55
+ }
56
+
57
+ function hostJson(root: string, name: string): Record<string, unknown> {
58
+ return JSON.parse(readFileSync(join(root, HOST_DIR, name), "utf8"));
59
+ }
60
+
61
+ function clean(root: string): void {
62
+ const project = loadProject(root);
63
+ expect(check(root, buildPlan(project), project.toolfactoryVersion)).toEqual([]);
64
+ expect(build(root).result.written).toEqual([]);
65
+ }
66
+
67
+ describe("OpenClaw skill loading and regeneration", () => {
68
+ it.each(["typescript", "python"] as const)(
69
+ "ships a fresh %s tool's own skill by default",
70
+ (binding) => {
71
+ const root = fixture({ binding });
72
+ expect(existsSync(join(root, "skills"))).toBe(false);
73
+ expect(loadProject(root).skills?.map((skill) => skill.name)).toEqual(["probe"]);
74
+ build(root);
75
+ const own = readFileSync(join(root, "skills/probe/SKILL.md"), "utf8");
76
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/SKILL.md"), "utf8")).toBe(own);
77
+ expect(own).toContain("Current operation");
78
+ expect(hostJson(root, "openclaw.plugin.json").skills).toEqual(["./skills"]);
79
+ expect(hostJson(root, "package.json").files).toContain("skills");
80
+ clean(root);
81
+ },
82
+ );
83
+
84
+ it.each([[], ["custom"], ["probe"], ["probe", "custom"]])(
85
+ "honors the exact explicit selection %j",
86
+ (...names) => {
87
+ const skills = names as string[];
88
+ const root = fixture({ openclaw: { skills } });
89
+ write(root, ".agents/skills/custom/SKILL.md", "Custom authored skill\n");
90
+ write(root, ".agents/skills/custom/references/guide.md", "Custom reference\n");
91
+ expect(loadProject(root).skills?.map((skill) => skill.name)).toEqual(skills);
92
+ build(root);
93
+ expect(existsSync(join(root, HOST_DIR, "skills/probe/SKILL.md"))).toBe(
94
+ skills.includes("probe"),
95
+ );
96
+ expect(existsSync(join(root, HOST_DIR, "skills/custom/SKILL.md"))).toBe(
97
+ skills.includes("custom"),
98
+ );
99
+ if (skills.includes("custom")) {
100
+ expect(
101
+ readFileSync(join(root, HOST_DIR, "skills/custom/references/guide.md"), "utf8"),
102
+ ).toBe("Custom reference\n");
103
+ }
104
+ expect(hostJson(root, "openclaw.plugin.json").skills).toEqual(
105
+ skills.length ? ["./skills"] : undefined,
106
+ );
107
+ expect((hostJson(root, "package.json").files as string[]).includes("skills")).toBe(
108
+ skills.length > 0,
109
+ );
110
+ clean(root);
111
+ },
112
+ );
113
+
114
+ it("refreshes generated regions in one build while retaining authored prose and text resources", () => {
115
+ const root = fixture({ openclaw: { skills: ["probe"] } });
116
+ build(root);
117
+ const path = "skills/probe/SKILL.md";
118
+ write(
119
+ root,
120
+ path,
121
+ readFileSync(join(root, path), "utf8").replace("# probe", "# probe\n\nAuthored procedure."),
122
+ );
123
+ write(root, "skills/probe/scripts/check.py", "print('retained')\n");
124
+ write(
125
+ root,
126
+ "plugin.json",
127
+ JSON.stringify({ name: "probe", version: "0.2.0", description: "Updated identity" }),
128
+ );
129
+ write(
130
+ root,
131
+ "dev.toolfactory/ops.json",
132
+ JSON.stringify({
133
+ tools: [
134
+ {
135
+ name: "new_operation",
136
+ description: "Updated operation",
137
+ inputSchema: { type: "object" },
138
+ },
139
+ ],
140
+ }),
141
+ );
142
+ build(root);
143
+ const bundled = readFileSync(join(root, HOST_DIR, path), "utf8");
144
+ expect(bundled).toBe(readFileSync(join(root, path), "utf8"));
145
+ expect(bundled).toContain("Authored procedure.");
146
+ expect(bundled).toContain("Updated identity");
147
+ expect(bundled).toContain("Updated operation");
148
+ expect(bundled).not.toContain("Current operation");
149
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/scripts/check.py"), "utf8")).toBe(
150
+ "print('retained')\n",
151
+ );
152
+ clean(root);
153
+ });
154
+
155
+ it("removes the default payload and manifest root when the author opts out", () => {
156
+ const root = fixture();
157
+ build(root);
158
+ const path = join(root, "dev.toolfactory/tool.json");
159
+ writeFileSync(
160
+ path,
161
+ JSON.stringify({ ...JSON.parse(readFileSync(path, "utf8")), openclaw: { skills: [] } }),
162
+ );
163
+ build(root);
164
+ expect(existsSync(join(root, HOST_DIR, "skills/probe/SKILL.md"))).toBe(false);
165
+ expect(existsSync(join(root, "skills/probe/SKILL.md"))).toBe(true);
166
+ expect(hostJson(root, "openclaw.plugin.json")).not.toHaveProperty("skills");
167
+ expect(hostJson(root, "package.json").files).not.toContain("skills");
168
+ clean(root);
169
+ });
170
+
171
+ it("packages an adopted own skill verbatim", () => {
172
+ const root = fixture();
173
+ build(root);
174
+ setState(root, "skills/probe/SKILL.md", "manual", loadProject(root).toolfactoryVersion);
175
+ write(root, "skills/probe/SKILL.md", "Fully authored skill without generator markers\n");
176
+ build(root);
177
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/SKILL.md"), "utf8")).toBe(
178
+ "Fully authored skill without generator markers\n",
179
+ );
180
+ clean(root);
181
+ });
182
+
183
+ it("retains an explicitly selected same-name custom canon when its alias was adopted", () => {
184
+ const root = fixture({ openclaw: { skills: ["probe"] } });
185
+ build(root);
186
+ setState(root, ".agents/skills/probe", "manual", loadProject(root).toolfactoryVersion);
187
+ rmSync(join(root, ".agents/skills/probe"));
188
+ write(root, ".agents/skills/probe/SKILL.md", "Custom same-name procedure\n");
189
+ write(root, ".agents/skills/probe/references/guide.md", "Custom same-name reference\n");
190
+ build(root);
191
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/SKILL.md"), "utf8")).toBe(
192
+ "Custom same-name procedure\n",
193
+ );
194
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/references/guide.md"), "utf8")).toBe(
195
+ "Custom same-name reference\n",
196
+ );
197
+ expect(readFileSync(join(root, "skills/probe/SKILL.md"), "utf8")).toContain(
198
+ "Current operation",
199
+ );
200
+ expect(readFileSync(join(root, ".agents/skills/probe/SKILL.md"), "utf8")).toBe(
201
+ "Custom same-name procedure\n",
202
+ );
203
+ clean(root);
204
+ });
205
+
206
+ it("does not infer a skill for projects without the selected pair and still validates declared custom skills", () => {
207
+ const hostOnly = fixture({ surfaces: ["openclaw-native"] });
208
+ build(hostOnly);
209
+ expect(loadProject(hostOnly).skills).toEqual([]);
210
+ expect(hostJson(hostOnly, "openclaw.plugin.json")).not.toHaveProperty("skills");
211
+ const instructions = fixture({ runtime: "none", surfaces: ["skill", "codex"] });
212
+ build(instructions);
213
+ expect(loadProject(instructions).skills).toEqual([]);
214
+ expect(existsSync(join(instructions, HOST_DIR))).toBe(false);
215
+ expect(readFileSync(join(instructions, "skills/probe/SKILL.md"), "utf8")).not.toContain(
216
+ "## Operations",
217
+ );
218
+ const custom = fixture({ surfaces: ["openclaw-native"], openclaw: { skills: ["custom"] } });
219
+ expect(() => loadProject(custom)).toThrow(/openclaw.skills declares "custom"/);
220
+ write(custom, ".agents/skills/custom/SKILL.md", "Custom only\n");
221
+ build(custom);
222
+ expect(readFileSync(join(custom, HOST_DIR, "skills/custom/SKILL.md"), "utf8")).toBe(
223
+ "Custom only\n",
224
+ );
225
+ clean(hostOnly);
226
+ clean(instructions);
227
+ clean(custom);
228
+ });
229
+ });
@@ -1,6 +1,6 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
- import { join, relative, resolve, sep } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { readIdentityFile } from "../identity/read.js";
6
6
  import {
@@ -14,7 +14,10 @@ import {
14
14
  type ProjectSkill,
15
15
  ToolConfigSchema,
16
16
  } from "../model.js";
17
- import { TOOLFACTORY_DIR } from "./lock.js";
17
+ import { has } from "../surfaces/shared.js";
18
+ import { skillFile } from "../surfaces/skill.js";
19
+ import { renderFile } from "./apply.js";
20
+ import { readLock, TOOLFACTORY_DIR } from "./lock.js";
18
21
 
19
22
  export const TOOL_PATH = `${TOOLFACTORY_DIR}/tool.json`;
20
23
  export const OPS_PATH = `${TOOLFACTORY_DIR}/ops.json`;
@@ -85,15 +88,33 @@ function readPackageManager(root: string): PackageManager | undefined {
85
88
  return name as PackageManager;
86
89
  }
87
90
 
88
- /** `.agents/skills/<name>/` as a shippable unit: every file, sorted, content in memory. */
89
- function readProjectSkill(root: string, name: string): ProjectSkill {
90
- const directory = join(root, ".agents", "skills", name);
91
- if (!existsSync(directory)) {
91
+ /** The selected skill as a shippable unit: every text file, sorted, content in memory. */
92
+ function readProjectSkill(project: Project, name: string): ProjectSkill {
93
+ const canonPath = `.agents/skills/${name}`;
94
+ const canon = join(project.root, canonPath);
95
+ const lock = readLock(project.root) ?? {
96
+ toolfactoryVersion: project.toolfactoryVersion,
97
+ files: {},
98
+ };
99
+ const entry = lstatSync(canon, { throwIfNoEntry: false });
100
+ const generatedAlias =
101
+ entry?.isSymbolicLink() &&
102
+ resolve(dirname(canon), readlinkSync(canon)) === join(project.root, "skills", name);
103
+ const custom =
104
+ project.tool.openclaw?.skills !== undefined &&
105
+ lock.files[canonPath]?.state === "manual" &&
106
+ !generatedAlias;
107
+ const own = has(project, "skill") && name === project.identity.name && !custom;
108
+ const planned = own ? skillFile(project) : undefined;
109
+ const directory = planned ? dirname(join(project.root, planned.path)) : canon;
110
+ if (!existsSync(directory) && !own) {
92
111
  throw new Error(
93
112
  `openclaw.skills declares "${name}" but ${join(".agents", "skills", name)} does not exist`,
94
113
  );
95
114
  }
96
- const files = readdirSync(directory, { recursive: true, withFileTypes: true })
115
+ const files = (
116
+ existsSync(directory) ? readdirSync(directory, { recursive: true, withFileTypes: true }) : []
117
+ )
97
118
  .filter((entry) => entry.isFile())
98
119
  .map((entry) => join(entry.parentPath, entry.name))
99
120
  .sort()
@@ -101,6 +122,19 @@ function readProjectSkill(root: string, name: string): ProjectSkill {
101
122
  path: relative(directory, absolute).replaceAll(sep, "/"),
102
123
  content: readFileSync(absolute, "utf8"),
103
124
  }));
125
+ if (planned) {
126
+ const content =
127
+ lock.files[planned.path]?.state === "manual"
128
+ ? readFileSync(join(project.root, planned.path), "utf8")
129
+ : renderFile(project.root, planned, lock);
130
+ return {
131
+ name,
132
+ files: [
133
+ ...files.filter((file) => file.path !== "SKILL.md"),
134
+ { path: "SKILL.md", content },
135
+ ].sort((a, b) => a.path.localeCompare(b.path)),
136
+ };
137
+ }
104
138
  return { name, files };
105
139
  }
106
140
 
@@ -112,14 +146,18 @@ export function loadProject(rootInput = "."): Project {
112
146
  }
113
147
  const tool = ToolConfigSchema.parse(JSON.parse(readFileSync(toolPath, "utf8")));
114
148
  const identityFile = readIdentityFile(join(root, tool.identity));
115
- return {
149
+ const project: Project = {
116
150
  root,
117
151
  tool,
118
152
  identity: identityFile.identity,
119
153
  identityExtra: identityFile.extra,
120
154
  operations: isInstructionOnly(tool) ? [] : readOps(root),
121
- skills: (tool.openclaw?.skills ?? []).map((name) => readProjectSkill(root, name)),
122
155
  toolfactoryVersion: TOOLFACTORY_VERSION,
123
156
  packageManager: readPackageManager(root),
124
157
  };
158
+ const names =
159
+ tool.openclaw?.skills ??
160
+ (has(project, "openclaw-native") && has(project, "skill") ? [project.identity.name] : []);
161
+ project.skills = names.map((name) => readProjectSkill(project, name));
162
+ return project;
125
163
  }
@@ -91,7 +91,9 @@ export function buildPlan(
91
91
  const manual = Object.entries(lock?.files ?? {})
92
92
  .filter(([, entry]) => entry.state === "manual")
93
93
  .map(([path]) => path);
94
- const total = Object.keys(lock?.files ?? {}).length || files.length;
94
+ // Count the plan being applied, including retained manual files, rather than the previous
95
+ // lock: newly bundled skill resources must not leave COVERAGE.md stale after one build.
96
+ const total = new Set([...files.map((file) => file.path), ...manual, "COVERAGE.md"]).size;
95
97
  files.push({
96
98
  kind: "file",
97
99
  path: "COVERAGE.md",
@@ -144,9 +144,9 @@ export function registrations(project: Project): Registration[] {
144
144
  return project.tool.openclaw?.registers ?? [];
145
145
  }
146
146
 
147
- /** Whether the plugin ships `.agents/skills/` directories (`openclaw.skills` in tool.json). */
147
+ /** Whether the loader resolved any skills for this plugin's payload. */
148
148
  export function shipsSkills(project: Project): boolean {
149
- return (project.tool.openclaw?.skills?.length ?? 0) > 0;
149
+ return (project.skills?.length ?? 0) > 0;
150
150
  }
151
151
 
152
152
  /**
@@ -11,6 +11,7 @@ import {
11
11
  isInstructionOnly,
12
12
  type Operation,
13
13
  type Project,
14
+ type RegionFile,
14
15
  type Surface,
15
16
  type Verdict,
16
17
  } from "../model.js";
@@ -86,28 +87,32 @@ export function frontmatter(project: Project): string {
86
87
  return `\n${yaml(document).trimEnd()}\n`;
87
88
  }
88
89
 
90
+ export function skillFile(project: Project): RegionFile {
91
+ const path = skillPath(project);
92
+ const regions = [
93
+ { begin: "---", end: "---", content: frontmatter(project) },
94
+ { begin: OPERATIONS_BEGIN, end: OPERATIONS_END, content: renderOperations(project) },
95
+ ];
96
+ const template = [
97
+ "---",
98
+ "---",
99
+ "",
100
+ `# ${project.identity.name}`,
101
+ "",
102
+ "Explain here when an agent should reach for this tool and how to combine its operations.",
103
+ "",
104
+ OPERATIONS_BEGIN,
105
+ OPERATIONS_END,
106
+ "",
107
+ ].join("\n");
108
+ return { kind: "region", path, regions, template };
109
+ }
110
+
89
111
  export const surface: Surface = {
90
112
  id: "skill",
91
113
  plan(project) {
92
- const path = skillPath(project);
93
- const regions = [
94
- { begin: "---", end: "---", content: frontmatter(project) },
95
- { begin: OPERATIONS_BEGIN, end: OPERATIONS_END, content: renderOperations(project) },
96
- ];
97
- const template = [
98
- "---",
99
- "---",
100
- "",
101
- `# ${project.identity.name}`,
102
- "",
103
- "Explain here when an agent should reach for this tool and how to combine its operations.",
104
- "",
105
- OPERATIONS_BEGIN,
106
- OPERATIONS_END,
107
- "",
108
- ].join("\n");
109
114
  return [
110
- { kind: "region", path, regions, template },
115
+ skillFile(project),
111
116
  {
112
117
  kind: "file",
113
118
  path: skillLinkPath(project),
@@ -149,12 +149,12 @@ export default defineConfig({
149
149
  preview: "vite preview",
150
150
  },
151
151
  dependencies: {
152
- "@base-ui/react": "^1.8.0",
152
+ "@base-ui/react": "^1.9.0",
153
153
  "@fontsource-variable/geist": "^5.3.0",
154
154
  "@tailwindcss/vite": "^4.3.3",
155
155
  "class-variance-authority": "^0.7.1",
156
156
  cn: "^0.4.0",
157
- "lucide-react": "^1.53.0",
157
+ "lucide-react": "^1.55.0",
158
158
  react: "^19.2.8",
159
159
  "react-dom": "^19.2.8",
160
160
  shadcn: "^4.21.4",
@@ -13,7 +13,7 @@ const operations = [...authored, web];
13
13
  const program = new Command()
14
14
  .name("toolfactory")
15
15
  .description("Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.")
16
- .version("0.4.4");
16
+ .version("0.4.6");
17
17
 
18
18
  for (const op of operations) {
19
19
  if (!serves(op, "cli")) continue;
@@ -33,7 +33,7 @@ const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
33
33
 
34
34
  /** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
35
35
  function createServer(): McpServer {
36
- const server = new McpServer({ name: "toolfactory", version: "0.4.4" });
36
+ const server = new McpServer({ name: "toolfactory", version: "0.4.6" });
37
37
  for (const op of operations) {
38
38
  if (!introspecting && !serves(op, "mcp")) continue;
39
39
  server.registerTool(