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/README.md +9 -1
- package/dist/e2e.test.js +32 -3
- package/dist/hosts/openclaw.js +3 -3
- package/dist/model.d.ts +1 -1
- package/dist/model.js +1 -1
- package/dist/project/apply.d.ts +3 -1
- package/dist/project/apply.js +3 -2
- package/dist/project/load.js +42 -10
- package/dist/project/load.test.d.ts +1 -0
- package/dist/project/load.test.js +158 -0
- package/dist/project/plan.js +3 -1
- package/dist/surfaces/openclaw-native.d.ts +1 -1
- package/dist/surfaces/openclaw-native.js +2 -2
- package/dist/surfaces/skill.d.ts +2 -1
- package/dist/surfaces/skill.js +21 -18
- package/dist/surfaces/web.d.ts +2 -2
- package/dist/surfaces/web.js +2 -2
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +1 -1
- package/src/e2e.test.ts +45 -2
- package/src/hosts/openclaw.ts +3 -3
- package/src/model.ts +2 -2
- package/src/project/apply.ts +3 -2
- package/src/project/load.test.ts +229 -0
- package/src/project/load.ts +48 -10
- package/src/project/plan.ts +3 -1
- package/src/surfaces/openclaw-native.ts +2 -2
- package/src/surfaces/skill.ts +23 -18
- package/src/surfaces/web.ts +2 -2
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/index-2fAlyVSV.js +10 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BAuTYPrZ.js +0 -10
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[] = [
|
|
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
|
-
|
|
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"]);
|
package/src/hosts/openclaw.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
"
|
|
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
|
-
/**
|
|
390
|
+
/** Resolved OpenClaw skills, including the generated default, read once so projectors stay pure. */
|
|
391
391
|
skills?: ProjectSkill[];
|
|
392
392
|
}
|
|
393
393
|
|
package/src/project/apply.ts
CHANGED
|
@@ -501,7 +501,8 @@ export function check(root: string, plan: PlannedFile[], toolfactoryVersion: str
|
|
|
501
501
|
return drift;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
|
|
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 =
|
|
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
|
+
});
|
package/src/project/load.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
89
|
-
function readProjectSkill(
|
|
90
|
-
const
|
|
91
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/src/project/plan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
147
|
+
/** Whether the loader resolved any skills for this plugin's payload. */
|
|
148
148
|
export function shipsSkills(project: Project): boolean {
|
|
149
|
-
return (project.
|
|
149
|
+
return (project.skills?.length ?? 0) > 0;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
package/src/surfaces/skill.ts
CHANGED
|
@@ -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
|
-
|
|
115
|
+
skillFile(project),
|
|
111
116
|
{
|
|
112
117
|
kind: "file",
|
|
113
118
|
path: skillLinkPath(project),
|
package/src/surfaces/web.ts
CHANGED
|
@@ -149,12 +149,12 @@ export default defineConfig({
|
|
|
149
149
|
preview: "vite preview",
|
|
150
150
|
},
|
|
151
151
|
dependencies: {
|
|
152
|
-
"@base-ui/react": "^1.
|
|
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.
|
|
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",
|
package/src/toolfactory/cli.ts
CHANGED
|
@@ -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.
|
|
16
|
+
.version("0.4.6");
|
|
17
17
|
|
|
18
18
|
for (const op of operations) {
|
|
19
19
|
if (!serves(op, "cli")) continue;
|
package/src/toolfactory/mcp.ts
CHANGED
|
@@ -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.
|
|
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(
|