toolfactory 0.4.5 → 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/README.md CHANGED
@@ -22,7 +22,7 @@ generated and every surface still installs, builds and publishes.
22
22
  - **Gemini CLI extension** — `gemini extensions install https://github.com/GoatInAHat/toolfactory`
23
23
  - **OpenClaw plugin** — `openclaw plugins install --link hosts/openclaw` from a checkout
24
24
  - **Hermes plugin** — `hermes plugins install https://github.com/GoatInAHat/toolfactory#hosts/hermes/toolfactory_hermes`
25
- - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.4.5.tgz`
25
+ - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.4.6.tgz`
26
26
  - **Web app** — `npx -y toolfactory mcp --http --open` serves the operations page beside the
27
27
  MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and
28
28
  returns the URL.
@@ -155,6 +155,14 @@ Toolfactory itself continues to use npm as its package registry.
155
155
 
156
156
  ## Driving toolfactory from an agent
157
157
 
158
+ Selecting both `skill` and `openclaw-native` ships the tool's own skill with the native plugin
159
+ by default. `openclaw.skills` is an exact override: `[]` opts out; `["my-tool", "custom"]`
160
+ ships the generated own skill and `.agents/skills/custom/`. An explicit custom-only list
161
+ continues to ship only those skills. The own skill's generated regions are refreshed in the
162
+ same build; authored prose and UTF-8 text resources are retained. The native manifest declares
163
+ `skills: ["./skills"]`, and npm packaging includes that directory. No native plugin skill is
164
+ inferred when the standalone `skill` surface is absent.
165
+
158
166
  Every command is an MCP tool, so a host can drive the whole loop without a shell — and nothing is
159
167
  registered by hand. Every generated project carries the `.agents/` canon, so `toolfactory` and the
160
168
  tool's own kernel are already entries in `.agents/mcp/servers.json`, which `bash .agents/setup`
package/dist/e2e.test.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * own node_modules isn't around to symlink in (no dependencies to run against).
5
5
  */
6
6
  import { execFileSync } from "node:child_process";
7
- import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync, } from "node:fs";
7
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync, } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, relative } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
@@ -18,7 +18,15 @@ describe.skipIf(!existsSync(repoNodeModules))("end-to-end: init -> introspect ->
18
18
  }, async () => {
19
19
  const dir = mkdtempSync(join(tmpdir(), "toolfactory-e2e-"));
20
20
  symlinkSync(repoNodeModules, join(dir, "node_modules"), "dir");
21
- const surfaces = ["skill", "agent-plugins", "claude", "mcp", "cli", "npm"];
21
+ const surfaces = [
22
+ "skill",
23
+ "agent-plugins",
24
+ "claude",
25
+ "mcp",
26
+ "cli",
27
+ "npm",
28
+ "openclaw-native",
29
+ ];
22
30
  const init = commands.init({
23
31
  root: dir,
24
32
  name: "hello",
@@ -33,8 +41,29 @@ describe.skipIf(!existsSync(repoNodeModules))("end-to-end: init -> introspect ->
33
41
  const echo = snapshot.ops.tools[0];
34
42
  expect(echo.inputSchema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
35
43
  expect(echo.outputSchema?.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
36
- commands.build(dir);
44
+ const skillPath = join(dir, "skills/hello/SKILL.md");
45
+ writeFileSync(skillPath, readFileSync(skillPath, "utf8").replace("# hello", "# hello\n\nAuthor procedure."));
46
+ mkdirSync(join(dir, "skills/hello/references"));
47
+ writeFileSync(join(dir, "skills/hello/references/guide.md"), "Retained reference.\n");
48
+ const opsPath = join(dir, "src/ops.ts");
49
+ writeFileSync(opsPath, readFileSync(opsPath, "utf8").replace("Return the text you pass in.", "Freshly rebuilt operation."));
50
+ await commands.introspect(dir);
37
51
  await expect(commands.check(dir)).resolves.toBeDefined();
52
+ expect(commands.build(dir).result.written).toEqual([]);
53
+ const bundled = readFileSync(join(dir, "hosts/openclaw/skills/hello/SKILL.md"), "utf8");
54
+ expect(bundled).toBe(readFileSync(skillPath, "utf8"));
55
+ expect(bundled).toContain("Author procedure.");
56
+ expect(bundled).toContain("Freshly rebuilt operation.");
57
+ expect(bundled).not.toContain("Return the text you pass in.");
58
+ const packed = JSON.parse(execFileSync("npm", ["pack", "./hosts/openclaw", "--dry-run", "--json"], {
59
+ cwd: dir,
60
+ encoding: "utf8",
61
+ }));
62
+ expect(packed[0]?.files.map((file) => file.path)).toEqual(expect.arrayContaining([
63
+ "openclaw.plugin.json",
64
+ "skills/hello/SKILL.md",
65
+ "skills/hello/references/guide.md",
66
+ ]));
38
67
  const opsOnDisk = JSON.parse(readFileSync(join(dir, "dev.toolfactory/ops.json"), "utf8"));
39
68
  expect(opsOnDisk.tools.map((t) => t.name)).toEqual(["echo"]);
40
69
  const output = execFileSync(process.execPath, ["--import", "tsx", "src/toolfactory/cli.ts", "echo", "--text", "hi"], { cwd: dir, encoding: "utf8" });
@@ -14,7 +14,7 @@ import { join } from "node:path";
14
14
  import { argv, exit, stderr, stdout } from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { loadProject } from "../project/load.js";
17
- import { HOST_DIR, OPENCLAW_SCAFFOLD, surface } from "../surfaces/openclaw-native.js";
17
+ import { HOST_DIR, OPENCLAW_SCAFFOLD, shipsSkills, surface } from "../surfaces/openclaw-native.js";
18
18
  export const DRIFT_ENTRY = fileURLToPath(import.meta.url);
19
19
  /** Per-tool values: the scaffold writes its own, and the projection is expected to differ. */
20
20
  const PER_TOOL_KEYS = new Set(["name", "version", "description"]);
@@ -35,7 +35,7 @@ function overridden(project) {
35
35
  paths.add(".openclaw.compat.pluginApi");
36
36
  }
37
37
  // Shipped skills append to `files`; upstream's entries must still survive (checked below).
38
- if (openclaw?.skills?.length)
38
+ if (shipsSkills(project))
39
39
  paths.add(".files");
40
40
  return paths;
41
41
  }
@@ -99,7 +99,7 @@ export function scaffoldDrift(project) {
99
99
  ? []
100
100
  : ["vitest.config.ts: upstream template changed"]),
101
101
  ];
102
- if (project.tool.openclaw?.skills?.length) {
102
+ if (shipsSkills(project)) {
103
103
  const upstream = JSON.parse(scaffold("package.json")).files ?? [];
104
104
  const ours = JSON.parse(planned(files, "package.json")).files ?? [];
105
105
  const lost = upstream.filter((entry) => !ours.includes(entry));
package/dist/model.d.ts CHANGED
@@ -281,7 +281,7 @@ export interface Project {
281
281
  toolfactoryVersion: string;
282
282
  /** From the root package.json `packageManager` field; npm when absent. */
283
283
  packageManager?: PackageManager;
284
- /** Skills `openclaw.skills` declares, read once so projectors stay pure. */
284
+ /** Resolved OpenClaw skills, including the generated default, read once so projectors stay pure. */
285
285
  skills?: ProjectSkill[];
286
286
  }
287
287
  export interface Surface {
package/dist/model.js CHANGED
@@ -194,7 +194,7 @@ export const ToolConfigSchema = z
194
194
  skills: z
195
195
  .array(z.string())
196
196
  .optional()
197
- .describe("Skill directories under `.agents/skills/` that the OpenClaw plugin ships; OpenClaw loads them through the manifest's `skills` roots."),
197
+ .describe("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."),
198
198
  dependencies: z.record(z.string(), z.string()).optional(),
199
199
  peerDependencies: z.record(z.string(), z.string()).optional(),
200
200
  devDependencies: z.record(z.string(), z.string()).optional(),
@@ -1,5 +1,5 @@
1
1
  import type { PlannedFile, Region } from "../model.js";
2
- import { type Markers } from "./lock.js";
2
+ import { type Lock, type Markers } from "./lock.js";
3
3
  export interface Drift {
4
4
  path: string;
5
5
  kind: "missing" | "changed" | "orphan" | "unmarked";
@@ -30,6 +30,8 @@ export declare function removeKeys(document: Record<string, unknown>, keys: read
30
30
  export declare function managedContent(file: PlannedFile): string;
31
31
  /** Compare a plan to the tree without writing. */
32
32
  export declare function check(root: string, plan: PlannedFile[], toolfactoryVersion: string): Drift[];
33
+ /** Read-only preview of the exact bytes apply writes, retaining authored regions and extensions. */
34
+ export declare function renderFile(root: string, file: PlannedFile, previous: Lock): string;
33
35
  /** Write a plan to the tree and refresh the lock. */
34
36
  export declare function apply(root: string, plan: PlannedFile[], toolfactoryVersion: string): ApplyResult;
35
37
  export declare function setState(root: string, path: string, state: "generated" | "manual", toolfactoryVersion: string): void;
@@ -390,7 +390,8 @@ export function check(root, plan, toolfactoryVersion) {
390
390
  }
391
391
  return drift;
392
392
  }
393
- function render(root, file, previous) {
393
+ /** Read-only preview of the exact bytes apply writes, retaining authored regions and extensions. */
394
+ export function renderFile(root, file, previous) {
394
395
  const path = join(root, file.path);
395
396
  if (file.kind === "file")
396
397
  return file.content;
@@ -468,7 +469,7 @@ export function apply(root, plan, toolfactoryVersion) {
468
469
  }
469
470
  }
470
471
  else {
471
- const next = render(root, file, previous);
472
+ const next = renderFile(root, file, previous);
472
473
  if (existsSync(path) && readFileSync(path, "utf8") === next) {
473
474
  result.unchanged.push(file.path);
474
475
  }
@@ -1,10 +1,13 @@
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 { CAPABILITIES, isInstructionOnly, PACKAGE_MANAGERS, ToolConfigSchema, } from "../model.js";
7
- import { TOOLFACTORY_DIR } from "./lock.js";
7
+ import { has } from "../surfaces/shared.js";
8
+ import { skillFile } from "../surfaces/skill.js";
9
+ import { renderFile } from "./apply.js";
10
+ import { readLock, TOOLFACTORY_DIR } from "./lock.js";
8
11
  export const TOOL_PATH = `${TOOLFACTORY_DIR}/tool.json`;
9
12
  export const OPS_PATH = `${TOOLFACTORY_DIR}/ops.json`;
10
13
  const require = createRequire(import.meta.url);
@@ -62,13 +65,27 @@ function readPackageManager(root) {
62
65
  }
63
66
  return name;
64
67
  }
65
- /** `.agents/skills/<name>/` as a shippable unit: every file, sorted, content in memory. */
66
- function readProjectSkill(root, name) {
67
- const directory = join(root, ".agents", "skills", name);
68
- if (!existsSync(directory)) {
68
+ /** The selected skill as a shippable unit: every text file, sorted, content in memory. */
69
+ function readProjectSkill(project, name) {
70
+ const canonPath = `.agents/skills/${name}`;
71
+ const canon = join(project.root, canonPath);
72
+ const lock = readLock(project.root) ?? {
73
+ toolfactoryVersion: project.toolfactoryVersion,
74
+ files: {},
75
+ };
76
+ const entry = lstatSync(canon, { throwIfNoEntry: false });
77
+ const generatedAlias = entry?.isSymbolicLink() &&
78
+ resolve(dirname(canon), readlinkSync(canon)) === join(project.root, "skills", name);
79
+ const custom = project.tool.openclaw?.skills !== undefined &&
80
+ lock.files[canonPath]?.state === "manual" &&
81
+ !generatedAlias;
82
+ const own = has(project, "skill") && name === project.identity.name && !custom;
83
+ const planned = own ? skillFile(project) : undefined;
84
+ const directory = planned ? dirname(join(project.root, planned.path)) : canon;
85
+ if (!existsSync(directory) && !own) {
69
86
  throw new Error(`openclaw.skills declares "${name}" but ${join(".agents", "skills", name)} does not exist`);
70
87
  }
71
- const files = readdirSync(directory, { recursive: true, withFileTypes: true })
88
+ const files = (existsSync(directory) ? readdirSync(directory, { recursive: true, withFileTypes: true }) : [])
72
89
  .filter((entry) => entry.isFile())
73
90
  .map((entry) => join(entry.parentPath, entry.name))
74
91
  .sort()
@@ -76,6 +93,18 @@ function readProjectSkill(root, name) {
76
93
  path: relative(directory, absolute).replaceAll(sep, "/"),
77
94
  content: readFileSync(absolute, "utf8"),
78
95
  }));
96
+ if (planned) {
97
+ const content = lock.files[planned.path]?.state === "manual"
98
+ ? readFileSync(join(project.root, planned.path), "utf8")
99
+ : renderFile(project.root, planned, lock);
100
+ return {
101
+ name,
102
+ files: [
103
+ ...files.filter((file) => file.path !== "SKILL.md"),
104
+ { path: "SKILL.md", content },
105
+ ].sort((a, b) => a.path.localeCompare(b.path)),
106
+ };
107
+ }
79
108
  return { name, files };
80
109
  }
81
110
  export function loadProject(rootInput = ".") {
@@ -86,14 +115,17 @@ export function loadProject(rootInput = ".") {
86
115
  }
87
116
  const tool = ToolConfigSchema.parse(JSON.parse(readFileSync(toolPath, "utf8")));
88
117
  const identityFile = readIdentityFile(join(root, tool.identity));
89
- return {
118
+ const project = {
90
119
  root,
91
120
  tool,
92
121
  identity: identityFile.identity,
93
122
  identityExtra: identityFile.extra,
94
123
  operations: isInstructionOnly(tool) ? [] : readOps(root),
95
- skills: (tool.openclaw?.skills ?? []).map((name) => readProjectSkill(root, name)),
96
124
  toolfactoryVersion: TOOLFACTORY_VERSION,
97
125
  packageManager: readPackageManager(root),
98
126
  };
127
+ const names = tool.openclaw?.skills ??
128
+ (has(project, "openclaw-native") && has(project, "skill") ? [project.identity.name] : []);
129
+ project.skills = names.map((name) => readProjectSkill(project, name));
130
+ return project;
99
131
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,158 @@
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 { HOST_DIR } from "../surfaces/openclaw-native.js";
7
+ import { check, setState } from "./apply.js";
8
+ import { loadProject } from "./load.js";
9
+ import { buildPlan } from "./plan.js";
10
+ const roots = [];
11
+ afterEach(() => {
12
+ for (const root of roots.splice(0))
13
+ rmSync(root, { recursive: true, force: true });
14
+ });
15
+ function write(root, path, content) {
16
+ mkdirSync(dirname(join(root, path)), { recursive: true });
17
+ writeFileSync(join(root, path), content);
18
+ }
19
+ function fixture(overrides = {}) {
20
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-skills-"));
21
+ roots.push(root);
22
+ write(root, "plugin.json", JSON.stringify({ name: "probe", version: "0.1.0", description: "Probe" }));
23
+ write(root, "dev.toolfactory/tool.json", JSON.stringify({
24
+ schemaVersion: 1,
25
+ identity: "plugin.json",
26
+ binding: "typescript",
27
+ surfaces: ["skill", "cli", "openclaw-native"],
28
+ ...overrides,
29
+ }));
30
+ write(root, "dev.toolfactory/ops.json", JSON.stringify({
31
+ tools: [
32
+ {
33
+ name: "echo",
34
+ description: "Current operation",
35
+ inputSchema: { type: "object" },
36
+ },
37
+ ],
38
+ }));
39
+ return root;
40
+ }
41
+ function hostJson(root, name) {
42
+ return JSON.parse(readFileSync(join(root, HOST_DIR, name), "utf8"));
43
+ }
44
+ function clean(root) {
45
+ const project = loadProject(root);
46
+ expect(check(root, buildPlan(project), project.toolfactoryVersion)).toEqual([]);
47
+ expect(build(root).result.written).toEqual([]);
48
+ }
49
+ describe("OpenClaw skill loading and regeneration", () => {
50
+ it.each(["typescript", "python"])("ships a fresh %s tool's own skill by default", (binding) => {
51
+ const root = fixture({ binding });
52
+ expect(existsSync(join(root, "skills"))).toBe(false);
53
+ expect(loadProject(root).skills?.map((skill) => skill.name)).toEqual(["probe"]);
54
+ build(root);
55
+ const own = readFileSync(join(root, "skills/probe/SKILL.md"), "utf8");
56
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/SKILL.md"), "utf8")).toBe(own);
57
+ expect(own).toContain("Current operation");
58
+ expect(hostJson(root, "openclaw.plugin.json").skills).toEqual(["./skills"]);
59
+ expect(hostJson(root, "package.json").files).toContain("skills");
60
+ clean(root);
61
+ });
62
+ it.each([[], ["custom"], ["probe"], ["probe", "custom"]])("honors the exact explicit selection %j", (...names) => {
63
+ const skills = names;
64
+ const root = fixture({ openclaw: { skills } });
65
+ write(root, ".agents/skills/custom/SKILL.md", "Custom authored skill\n");
66
+ write(root, ".agents/skills/custom/references/guide.md", "Custom reference\n");
67
+ expect(loadProject(root).skills?.map((skill) => skill.name)).toEqual(skills);
68
+ build(root);
69
+ expect(existsSync(join(root, HOST_DIR, "skills/probe/SKILL.md"))).toBe(skills.includes("probe"));
70
+ expect(existsSync(join(root, HOST_DIR, "skills/custom/SKILL.md"))).toBe(skills.includes("custom"));
71
+ if (skills.includes("custom")) {
72
+ expect(readFileSync(join(root, HOST_DIR, "skills/custom/references/guide.md"), "utf8")).toBe("Custom reference\n");
73
+ }
74
+ expect(hostJson(root, "openclaw.plugin.json").skills).toEqual(skills.length ? ["./skills"] : undefined);
75
+ expect(hostJson(root, "package.json").files.includes("skills")).toBe(skills.length > 0);
76
+ clean(root);
77
+ });
78
+ it("refreshes generated regions in one build while retaining authored prose and text resources", () => {
79
+ const root = fixture({ openclaw: { skills: ["probe"] } });
80
+ build(root);
81
+ const path = "skills/probe/SKILL.md";
82
+ write(root, path, readFileSync(join(root, path), "utf8").replace("# probe", "# probe\n\nAuthored procedure."));
83
+ write(root, "skills/probe/scripts/check.py", "print('retained')\n");
84
+ write(root, "plugin.json", JSON.stringify({ name: "probe", version: "0.2.0", description: "Updated identity" }));
85
+ write(root, "dev.toolfactory/ops.json", JSON.stringify({
86
+ tools: [
87
+ {
88
+ name: "new_operation",
89
+ description: "Updated operation",
90
+ inputSchema: { type: "object" },
91
+ },
92
+ ],
93
+ }));
94
+ build(root);
95
+ const bundled = readFileSync(join(root, HOST_DIR, path), "utf8");
96
+ expect(bundled).toBe(readFileSync(join(root, path), "utf8"));
97
+ expect(bundled).toContain("Authored procedure.");
98
+ expect(bundled).toContain("Updated identity");
99
+ expect(bundled).toContain("Updated operation");
100
+ expect(bundled).not.toContain("Current operation");
101
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/scripts/check.py"), "utf8")).toBe("print('retained')\n");
102
+ clean(root);
103
+ });
104
+ it("removes the default payload and manifest root when the author opts out", () => {
105
+ const root = fixture();
106
+ build(root);
107
+ const path = join(root, "dev.toolfactory/tool.json");
108
+ writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, "utf8")), openclaw: { skills: [] } }));
109
+ build(root);
110
+ expect(existsSync(join(root, HOST_DIR, "skills/probe/SKILL.md"))).toBe(false);
111
+ expect(existsSync(join(root, "skills/probe/SKILL.md"))).toBe(true);
112
+ expect(hostJson(root, "openclaw.plugin.json")).not.toHaveProperty("skills");
113
+ expect(hostJson(root, "package.json").files).not.toContain("skills");
114
+ clean(root);
115
+ });
116
+ it("packages an adopted own skill verbatim", () => {
117
+ const root = fixture();
118
+ build(root);
119
+ setState(root, "skills/probe/SKILL.md", "manual", loadProject(root).toolfactoryVersion);
120
+ write(root, "skills/probe/SKILL.md", "Fully authored skill without generator markers\n");
121
+ build(root);
122
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/SKILL.md"), "utf8")).toBe("Fully authored skill without generator markers\n");
123
+ clean(root);
124
+ });
125
+ it("retains an explicitly selected same-name custom canon when its alias was adopted", () => {
126
+ const root = fixture({ openclaw: { skills: ["probe"] } });
127
+ build(root);
128
+ setState(root, ".agents/skills/probe", "manual", loadProject(root).toolfactoryVersion);
129
+ rmSync(join(root, ".agents/skills/probe"));
130
+ write(root, ".agents/skills/probe/SKILL.md", "Custom same-name procedure\n");
131
+ write(root, ".agents/skills/probe/references/guide.md", "Custom same-name reference\n");
132
+ build(root);
133
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/SKILL.md"), "utf8")).toBe("Custom same-name procedure\n");
134
+ expect(readFileSync(join(root, HOST_DIR, "skills/probe/references/guide.md"), "utf8")).toBe("Custom same-name reference\n");
135
+ expect(readFileSync(join(root, "skills/probe/SKILL.md"), "utf8")).toContain("Current operation");
136
+ expect(readFileSync(join(root, ".agents/skills/probe/SKILL.md"), "utf8")).toBe("Custom same-name procedure\n");
137
+ clean(root);
138
+ });
139
+ it("does not infer a skill for projects without the selected pair and still validates declared custom skills", () => {
140
+ const hostOnly = fixture({ surfaces: ["openclaw-native"] });
141
+ build(hostOnly);
142
+ expect(loadProject(hostOnly).skills).toEqual([]);
143
+ expect(hostJson(hostOnly, "openclaw.plugin.json")).not.toHaveProperty("skills");
144
+ const instructions = fixture({ runtime: "none", surfaces: ["skill", "codex"] });
145
+ build(instructions);
146
+ expect(loadProject(instructions).skills).toEqual([]);
147
+ expect(existsSync(join(instructions, HOST_DIR))).toBe(false);
148
+ expect(readFileSync(join(instructions, "skills/probe/SKILL.md"), "utf8")).not.toContain("## Operations");
149
+ const custom = fixture({ surfaces: ["openclaw-native"], openclaw: { skills: ["custom"] } });
150
+ expect(() => loadProject(custom)).toThrow(/openclaw.skills declares "custom"/);
151
+ write(custom, ".agents/skills/custom/SKILL.md", "Custom only\n");
152
+ build(custom);
153
+ expect(readFileSync(join(custom, HOST_DIR, "skills/custom/SKILL.md"), "utf8")).toBe("Custom only\n");
154
+ clean(hostOnly);
155
+ clean(instructions);
156
+ clean(custom);
157
+ });
158
+ });
@@ -86,7 +86,9 @@ export function buildPlan(project, surfaces = selectedSurfaces(project.tool.surf
86
86
  const manual = Object.entries(lock?.files ?? {})
87
87
  .filter(([, entry]) => entry.state === "manual")
88
88
  .map(([path]) => path);
89
- const total = Object.keys(lock?.files ?? {}).length || files.length;
89
+ // Count the plan being applied, including retained manual files, rather than the previous
90
+ // lock: newly bundled skill resources must not leave COVERAGE.md stale after one build.
91
+ const total = new Set([...files.map((file) => file.path), ...manual, "COVERAGE.md"]).size;
90
92
  files.push({
91
93
  kind: "file",
92
94
  path: "COVERAGE.md",
@@ -96,7 +96,7 @@ export declare const ENTRY_END = "// /tf:entry";
96
96
  export declare function displayName(id: string): string;
97
97
  /** Host registrations the plugin declares beyond tools. */
98
98
  export declare function registrations(project: Project): Registration[];
99
- /** Whether the plugin ships `.agents/skills/` directories (`openclaw.skills` in tool.json). */
99
+ /** Whether the loader resolved any skills for this plugin's payload. */
100
100
  export declare function shipsSkills(project: Project): boolean;
101
101
  /** `activation` for both the entry and the manifest; OpenClaw's own default is startup. */
102
102
  export declare function activation(project: Project): Record<string, unknown>;
@@ -124,9 +124,9 @@ export function displayName(id) {
124
124
  export function registrations(project) {
125
125
  return project.tool.openclaw?.registers ?? [];
126
126
  }
127
- /** Whether the plugin ships `.agents/skills/` directories (`openclaw.skills` in tool.json). */
127
+ /** Whether the loader resolved any skills for this plugin's payload. */
128
128
  export function shipsSkills(project) {
129
- return (project.tool.openclaw?.skills?.length ?? 0) > 0;
129
+ return (project.skills?.length ?? 0) > 0;
130
130
  }
131
131
  /**
132
132
  * The gateway route and the Control UI tab the plugin adds when the tool also has a `web`
@@ -1,7 +1,8 @@
1
- import { type Project, type Surface } from "../model.js";
1
+ import { type Project, type RegionFile, type Surface } from "../model.js";
2
2
  export declare const OPERATIONS_BEGIN = "<!-- tf:operations -->";
3
3
  export declare const OPERATIONS_END = "<!-- /tf:operations -->";
4
4
  export declare function skillPath(project: Project): string;
5
5
  export declare function renderOperations(project: Project): string;
6
6
  export declare function frontmatter(project: Project): string;
7
+ export declare function skillFile(project: Project): RegionFile;
7
8
  export declare const surface: Surface;
@@ -68,28 +68,31 @@ export function frontmatter(project) {
68
68
  });
69
69
  return `\n${yaml(document).trimEnd()}\n`;
70
70
  }
71
+ export function skillFile(project) {
72
+ const path = skillPath(project);
73
+ const regions = [
74
+ { begin: "---", end: "---", content: frontmatter(project) },
75
+ { begin: OPERATIONS_BEGIN, end: OPERATIONS_END, content: renderOperations(project) },
76
+ ];
77
+ const template = [
78
+ "---",
79
+ "---",
80
+ "",
81
+ `# ${project.identity.name}`,
82
+ "",
83
+ "Explain here when an agent should reach for this tool and how to combine its operations.",
84
+ "",
85
+ OPERATIONS_BEGIN,
86
+ OPERATIONS_END,
87
+ "",
88
+ ].join("\n");
89
+ return { kind: "region", path, regions, template };
90
+ }
71
91
  export const surface = {
72
92
  id: "skill",
73
93
  plan(project) {
74
- const path = skillPath(project);
75
- const regions = [
76
- { begin: "---", end: "---", content: frontmatter(project) },
77
- { begin: OPERATIONS_BEGIN, end: OPERATIONS_END, content: renderOperations(project) },
78
- ];
79
- const template = [
80
- "---",
81
- "---",
82
- "",
83
- `# ${project.identity.name}`,
84
- "",
85
- "Explain here when an agent should reach for this tool and how to combine its operations.",
86
- "",
87
- OPERATIONS_BEGIN,
88
- OPERATIONS_END,
89
- "",
90
- ].join("\n");
91
94
  return [
92
- { kind: "region", path, regions, template },
95
+ skillFile(project),
93
96
  {
94
97
  kind: "file",
95
98
  path: skillLinkPath(project),
@@ -11,7 +11,7 @@ const operations = [...authored, web];
11
11
  const program = new Command()
12
12
  .name("toolfactory")
13
13
  .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.")
14
- .version("0.4.5");
14
+ .version("0.4.6");
15
15
  for (const op of operations) {
16
16
  if (!serves(op, "cli"))
17
17
  continue;
@@ -30,7 +30,7 @@ const operations = [...authored, web];
30
30
  const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
31
31
  /** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
32
32
  function createServer() {
33
- const server = new McpServer({ name: "toolfactory", version: "0.4.5" });
33
+ const server = new McpServer({ name: "toolfactory", version: "0.4.6" });
34
34
  for (const op of operations) {
35
35
  if (!introspecting && !serves(op, "mcp"))
36
36
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolfactory",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "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.",
5
5
  "license": "MIT",
6
6
  "repository": {
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));