toolfactory 0.3.1 → 0.4.2
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 +1 -1
- package/dist/bindings/typescript.test.js +11 -0
- package/dist/commands.js +26 -2
- package/dist/commands.test.js +30 -0
- package/dist/hosts/github.js +2 -3
- package/dist/hosts/openclaw.js +3 -2
- package/dist/model.d.ts +4 -0
- package/dist/model.js +16 -1
- package/dist/project/gate.js +3 -4
- package/dist/project/history.d.ts +4 -0
- package/dist/surfaces/agents.js +7 -1
- package/dist/surfaces/agents.test.js +1 -0
- package/dist/surfaces/openclaw-native.js +1 -0
- package/dist/surfaces/openclaw-native.test.js +4 -1
- package/dist/surfaces/shared.js +2 -0
- package/dist/surfaces/vscode-extension.js +1 -1
- package/dist/surfaces/web.d.ts +2 -2
- package/dist/surfaces/web.js +2 -2
- package/dist/surfaces/workflows.js +8 -1
- package/dist/surfaces/workflows.test.js +64 -1
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +1 -1
- package/src/bindings/typescript.test.ts +13 -0
- package/src/commands.test.ts +34 -0
- package/src/commands.ts +29 -2
- package/src/hosts/github.ts +2 -3
- package/src/hosts/openclaw.ts +3 -3
- package/src/model.ts +20 -1
- package/src/project/gate.ts +3 -4
- package/src/surfaces/agents.test.ts +1 -0
- package/src/surfaces/agents.ts +7 -1
- package/src/surfaces/openclaw-native.test.ts +4 -1
- package/src/surfaces/openclaw-native.ts +1 -0
- package/src/surfaces/shared.ts +1 -0
- package/src/surfaces/vscode-extension.ts +1 -1
- package/src/surfaces/web.ts +2 -2
- package/src/surfaces/workflows.test.ts +75 -1
- package/src/surfaces/workflows.ts +9 -3
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/index-AwHZabeT.js +10 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-D95aMjZ6.js +0 -10
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.
|
|
25
|
+
- **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.4.2.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.
|
|
@@ -88,6 +88,17 @@ describe("typescript binding", () => {
|
|
|
88
88
|
const scripts = JSON.parse(text(scaffold(project(["cli"])), "package.json")).scripts;
|
|
89
89
|
expect(scripts["test:live"]).toBe("node --env-file-if-exists=.env node_modules/vitest/vitest.mjs run tests/live.test.ts");
|
|
90
90
|
});
|
|
91
|
+
it("supports a host-vault live gate without making credentials globally required config", () => {
|
|
92
|
+
const base = project(["cli"]);
|
|
93
|
+
base.tool.tests.live = { credentials: ["LIVE_VAULT_READY"], ci: false };
|
|
94
|
+
base.operations = [{ name: "read", inputSchema: { type: "object" }, requires: [] }];
|
|
95
|
+
const live = liveTest(base)[0];
|
|
96
|
+
if (live?.kind !== "region")
|
|
97
|
+
throw new Error("expected a region file");
|
|
98
|
+
expect(live.regions[0]?.content).toContain('const CREDENTIALS = ["LIVE_VAULT_READY"];');
|
|
99
|
+
expect(base.tool.config?.required).toBeUndefined();
|
|
100
|
+
expect(ToolConfigSchema.safeParse({ ...base.tool, tests: { live: { credentials: [] } } }).success).toBe(false);
|
|
101
|
+
});
|
|
91
102
|
it("emits opt-in --http and --pair flags, defaulting to stdio, on the mcp module and the cli", () => {
|
|
92
103
|
const files = [...kernel(project(["mcp", "cli"])), ...cliFiles(project(["mcp", "cli"]))];
|
|
93
104
|
const mcp = text(files, "src/toolfactory/mcp.ts");
|
package/dist/commands.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
|
-
import {
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
9
10
|
import { parseEnv } from "node:util";
|
|
10
11
|
import { getBinding } from "./bindings/index.js";
|
|
11
12
|
import { LIVE_TEST_COMMAND } from "./bindings/python.js";
|
|
@@ -72,6 +73,26 @@ function agentSetup(root) {
|
|
|
72
73
|
.flatMap((line) => line.match(/^Generated ([^:]+):/)?.slice(1, 2) ?? []);
|
|
73
74
|
return { setup: result.status === 0, harnesses };
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The shared OpenClaw project registry (owner decision 2026-09-19, `docs/adoption.md`): a tool
|
|
78
|
+
* scaffolded under `~/.openclaw/projects/<id>` gets its project row at `init` time, best-effort
|
|
79
|
+
* the way `.agents/setup` is — no reachable gateway (or CLI) still leaves a scaffolded project,
|
|
80
|
+
* and the next step carries the exact call to run. Session creation stays with the calling agent.
|
|
81
|
+
* TOOLFACTORY_OPENCLAW_PROJECTS_ROOT overrides the registry root (deployments, tests).
|
|
82
|
+
*/
|
|
83
|
+
function registerOpenClawProject(root, dryRun) {
|
|
84
|
+
const registryRoot = process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT ?? join(homedir(), ".openclaw", "projects");
|
|
85
|
+
const beneath = relative(registryRoot, root);
|
|
86
|
+
if (!beneath || beneath.startsWith("..") || resolve(beneath) === beneath)
|
|
87
|
+
return undefined;
|
|
88
|
+
const args = ["gateway", "call", "projects.register", "--params", JSON.stringify({ path: root })];
|
|
89
|
+
if (dryRun)
|
|
90
|
+
return ("OpenClaw: run `openclaw " + args.join(" ") + "` to add it to the shared project registry.");
|
|
91
|
+
const registered = spawnSync("openclaw", args, { timeout: 60_000 });
|
|
92
|
+
return registered.status === 0
|
|
93
|
+
? "OpenClaw: registered in the shared project registry; bind one visible dashboard session to it for ongoing work."
|
|
94
|
+
: "OpenClaw: registration did not succeed — run the `openclaw gateway call projects.register` step once the gateway is reachable.";
|
|
95
|
+
}
|
|
75
96
|
function nextSteps(project, agentConfig) {
|
|
76
97
|
const cli = toolfactoryCli(project);
|
|
77
98
|
const ops = project.tool.binding === "typescript"
|
|
@@ -194,10 +215,13 @@ export function init(options) {
|
|
|
194
215
|
git(root, [...commitIdentity(root), "commit", "-q", "--no-verify", "-m", "toolfactory init"]);
|
|
195
216
|
}
|
|
196
217
|
}
|
|
218
|
+
const steps = nextSteps(built.project, agentConfig);
|
|
219
|
+
// Last, because registration wants the committed repository on record, not a bare scaffold.
|
|
220
|
+
const openclaw = registerOpenClawProject(root, options.dryRun === true);
|
|
197
221
|
const result = {
|
|
198
222
|
written,
|
|
199
223
|
agentConfig,
|
|
200
|
-
nextSteps:
|
|
224
|
+
nextSteps: openclaw ? [...steps, openclaw] : steps,
|
|
201
225
|
};
|
|
202
226
|
if (options.repo) {
|
|
203
227
|
const project = loadProject(root);
|
package/dist/commands.test.js
CHANGED
|
@@ -213,3 +213,33 @@ describe("unpublish", () => {
|
|
|
213
213
|
expect(result.steps.some((step) => step.run.includes("npm deprecate probe@'*'"))).toBe(true);
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
|
+
describe("registerOpenClawProject", () => {
|
|
217
|
+
it("reports the exact register call for a tool scaffolded under the shared registry (dry-run)", () => {
|
|
218
|
+
const registry = join(mkdtempSync(join(tmpdir(), "tf-home-")), ".openclaw", "projects");
|
|
219
|
+
process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT = registry;
|
|
220
|
+
const root = join(registry, "probe");
|
|
221
|
+
const result = commands.init({
|
|
222
|
+
root,
|
|
223
|
+
name: "probe",
|
|
224
|
+
binding: "typescript",
|
|
225
|
+
setup: false,
|
|
226
|
+
git: false,
|
|
227
|
+
dryRun: true,
|
|
228
|
+
});
|
|
229
|
+
const line = result.nextSteps.find((step) => step.startsWith("OpenClaw:"));
|
|
230
|
+
expect(line).toBe("OpenClaw: run `openclaw gateway call projects.register --params " +
|
|
231
|
+
JSON.stringify({ path: root }) +
|
|
232
|
+
"` to add it to the shared project registry.");
|
|
233
|
+
});
|
|
234
|
+
it("says nothing outside the shared registry", () => {
|
|
235
|
+
const result = commands.init({
|
|
236
|
+
root: join(tmpdir(), "tf-plain"),
|
|
237
|
+
name: "probe",
|
|
238
|
+
binding: "typescript",
|
|
239
|
+
setup: false,
|
|
240
|
+
git: false,
|
|
241
|
+
dryRun: true,
|
|
242
|
+
});
|
|
243
|
+
expect(result.nextSteps.some((step) => step.startsWith("OpenClaw:"))).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
});
|
package/dist/hosts/github.js
CHANGED
|
@@ -45,12 +45,11 @@ export function bootstrapRepo(project, options = {}) {
|
|
|
45
45
|
const manual = (options.manual ?? []).filter((step) => !step.startsWith("npm:"));
|
|
46
46
|
const envFile = options.envFile ?? join(project.root, ".env");
|
|
47
47
|
const values = existsSync(envFile) ? parseEnv(readFileSync(envFile, "utf8")) : {};
|
|
48
|
-
const keys = liveCredentials(project);
|
|
48
|
+
const keys = project.tool.tests.live?.ci === false ? [] : liveCredentials(project);
|
|
49
49
|
const commands = [];
|
|
50
50
|
const written = [];
|
|
51
51
|
const missing = [];
|
|
52
|
-
//
|
|
53
|
-
// config key that is both required and sensitive is the whole trigger (§6 T4).
|
|
52
|
+
// A local-vault-only live tier does not need a GitHub environment or copied account secrets.
|
|
54
53
|
if (keys.length) {
|
|
55
54
|
const ids = reviewers.map((login) => {
|
|
56
55
|
const args = ["api", `users/${login}`, "--jq", ".id"];
|
package/dist/hosts/openclaw.js
CHANGED
|
@@ -24,7 +24,8 @@ const PER_TOOL_KEYS = new Set(["name", "version", "description"]);
|
|
|
24
24
|
*/
|
|
25
25
|
function overridden(project) {
|
|
26
26
|
const openclaw = project.tool.openclaw;
|
|
27
|
-
|
|
27
|
+
// The host development dependency is deliberately pinned to the recorded scaffold release.
|
|
28
|
+
const paths = new Set([".devDependencies.openclaw"]);
|
|
28
29
|
for (const section of ["dependencies", "peerDependencies", "devDependencies"]) {
|
|
29
30
|
for (const key of Object.keys(openclaw?.[section] ?? {}))
|
|
30
31
|
paths.add(`.${section}.${key}`);
|
|
@@ -74,7 +75,7 @@ export function scaffoldDrift(project) {
|
|
|
74
75
|
// The probe must run the exact openclaw this project pins (author override or the
|
|
75
76
|
// scaffold default) - never whatever happens to be on PATH, which made the drift verdict
|
|
76
77
|
// environment-dependent: the scaffold openclawVersion is the running CLI own version.
|
|
77
|
-
const pin = project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.
|
|
78
|
+
const pin = project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.openclawVersion;
|
|
78
79
|
const pinned = join(project.root, HOST_DIR, "node_modules/.bin/openclaw");
|
|
79
80
|
try {
|
|
80
81
|
const probe = existsSync(pinned)
|
package/dist/model.d.ts
CHANGED
|
@@ -89,6 +89,10 @@ export declare const ToolConfigSchema: z.ZodObject<{
|
|
|
89
89
|
config: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
90
90
|
tests: z.ZodDefault<z.ZodObject<{
|
|
91
91
|
examples: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
|
|
92
|
+
live: z.ZodOptional<z.ZodObject<{
|
|
93
|
+
credentials: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
94
|
+
ci: z.ZodOptional<z.ZodBoolean>;
|
|
95
|
+
}, z.core.$strip>>;
|
|
92
96
|
}, z.core.$strip>>;
|
|
93
97
|
npm: z.ZodOptional<z.ZodObject<{
|
|
94
98
|
scope: z.ZodOptional<z.ZodString>;
|
package/dist/model.js
CHANGED
|
@@ -102,7 +102,22 @@ export const ToolConfigSchema = z
|
|
|
102
102
|
property.default === undefined), "A sensitive config property must not declare a default: it would be committed into every manifest.")
|
|
103
103
|
.describe("JSON Schema 2020-12 object for the tool's configuration; mark secrets with x-toolfactory.sensitive."),
|
|
104
104
|
tests: z
|
|
105
|
-
.object({
|
|
105
|
+
.object({
|
|
106
|
+
examples: z.record(z.string(), jsonObject).default({}),
|
|
107
|
+
live: z
|
|
108
|
+
.object({
|
|
109
|
+
credentials: z
|
|
110
|
+
.array(z.string().regex(/^[A-Z_][A-Z0-9_]*$/))
|
|
111
|
+
.min(1)
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("Environment gates for live tests when auth is supplied outside tool config, such as a host vault. Names only, never values."),
|
|
114
|
+
ci: z
|
|
115
|
+
.boolean()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Set false for live tests that require a local vault or browser unavailable on hosted CI. The native local live-test tier is still generated."),
|
|
118
|
+
})
|
|
119
|
+
.optional(),
|
|
120
|
+
})
|
|
106
121
|
.default({ examples: {} })
|
|
107
122
|
.describe("Example arguments per operation, used by the generated surface-smoke tests."),
|
|
108
123
|
npm: z.object({ scope: z.string().optional() }).optional(),
|
package/dist/project/gate.js
CHANGED
|
@@ -258,11 +258,10 @@ export function packageSteps(project, options = {}) {
|
|
|
258
258
|
run: [
|
|
259
259
|
`npm --prefix ${OPENCLAW_HOST_DIR} install`,
|
|
260
260
|
`npm --prefix ${OPENCLAW_HOST_DIR} run build`,
|
|
261
|
-
`v=$(node -p "require('./package.json').version")`,
|
|
262
261
|
`cp ${OPENCLAW_HOST_DIR}/package.json ${OPENCLAW_HOST_DIR}/package.json.prepack`,
|
|
263
|
-
`(
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
`(trap 'mv ${OPENCLAW_HOST_DIR}/package.json.prepack ${OPENCLAW_HOST_DIR}/package.json' EXIT; ` +
|
|
263
|
+
`(cd ${OPENCLAW_HOST_DIR} && npm pkg set 'dependencies.${npmName(project)}=${project.identity.version}') && ` +
|
|
264
|
+
`npm pack ./${OPENCLAW_HOST_DIR} --pack-destination ${RELEASE_DIR})`,
|
|
266
265
|
].join(" && "),
|
|
267
266
|
});
|
|
268
267
|
}
|
|
@@ -16,6 +16,10 @@ export declare function toolAtRef(root: string, ref: string): {
|
|
|
16
16
|
};
|
|
17
17
|
tests: {
|
|
18
18
|
examples: Record<string, Record<string, unknown>>;
|
|
19
|
+
live?: {
|
|
20
|
+
credentials?: string[] | undefined;
|
|
21
|
+
ci?: boolean | undefined;
|
|
22
|
+
} | undefined;
|
|
19
23
|
};
|
|
20
24
|
$schema?: string | undefined;
|
|
21
25
|
runtime?: "kernel" | "none" | undefined;
|
package/dist/surfaces/agents.js
CHANGED
|
@@ -519,7 +519,13 @@ function setupTail(project) {
|
|
|
519
519
|
" exit 1",
|
|
520
520
|
"fi",
|
|
521
521
|
]
|
|
522
|
-
: [
|
|
522
|
+
: [
|
|
523
|
+
"if [ -f package-lock.json ]; then",
|
|
524
|
+
" npm ci --no-audit --no-fund",
|
|
525
|
+
"else",
|
|
526
|
+
" npm install --no-audit --no-fund",
|
|
527
|
+
"fi",
|
|
528
|
+
];
|
|
523
529
|
return [
|
|
524
530
|
"",
|
|
525
531
|
"# The pre-commit hook installed above converges agent config; toolfactory's drift gate goes in",
|
|
@@ -106,6 +106,7 @@ describe("agents", () => {
|
|
|
106
106
|
expect(setup).not.toContain("# Project setup goes here");
|
|
107
107
|
// Owner ask #11: a commit cannot leave a generated file stale, with or without CI.
|
|
108
108
|
expect(setup).toContain("npx toolfactory check >/dev/null");
|
|
109
|
+
expect(setup).toContain("if [ -f package-lock.json ]; then\n npm ci --no-audit --no-fund");
|
|
109
110
|
expect(setup).toContain("npm install --no-audit --no-fund");
|
|
110
111
|
});
|
|
111
112
|
it("installs uv before Python projects run the vendored setup workflow", () => {
|
|
@@ -251,6 +251,7 @@ function packageJson(project, operations, kase) {
|
|
|
251
251
|
dependencies: { ...OPENCLAW_SCAFFOLD.dependencies, ...core, ...extra?.dependencies },
|
|
252
252
|
devDependencies: {
|
|
253
253
|
...OPENCLAW_SCAFFOLD.devDependencies,
|
|
254
|
+
openclaw: openclawVersion(project),
|
|
254
255
|
...(kase ? { "@copilotkit/aimock": OPENCLAW_ADDITIONS.aimock } : {}),
|
|
255
256
|
...extra?.devDependencies,
|
|
256
257
|
},
|
|
@@ -117,7 +117,10 @@ describe("openclaw-native", () => {
|
|
|
117
117
|
expect(files[`${HOST_DIR}/README.md`]).toContain("excluded:implement-in-hosts");
|
|
118
118
|
expect(files[`${HOST_DIR}/README.md`]).toContain("`shoot` — requires browser");
|
|
119
119
|
const pkg = JSON.parse(files[`${HOST_DIR}/package.json`] ?? "{}");
|
|
120
|
-
expect(pkg.devDependencies).toMatchObject(
|
|
120
|
+
expect(pkg.devDependencies).toMatchObject({
|
|
121
|
+
...OPENCLAW_SCAFFOLD.devDependencies,
|
|
122
|
+
openclaw: OPENCLAW_SCAFFOLD.openclawVersion,
|
|
123
|
+
});
|
|
121
124
|
expect(pkg.openclaw).toEqual({
|
|
122
125
|
extensions: ["./dist/index.js"],
|
|
123
126
|
compat: { pluginApi: OPENCLAW_SCAFFOLD.pluginApi },
|
package/dist/surfaces/shared.js
CHANGED
|
@@ -94,6 +94,8 @@ export function dataDirEnvName(project) {
|
|
|
94
94
|
* A tool with none has nothing to gate a live tier on, so no live test and no `live` CI job.
|
|
95
95
|
*/
|
|
96
96
|
export function liveCredentials(project) {
|
|
97
|
+
if (project.tool.tests.live?.credentials)
|
|
98
|
+
return project.tool.tests.live.credentials;
|
|
97
99
|
const properties = configProperties(project);
|
|
98
100
|
const required = new Set(requiredConfig(project));
|
|
99
101
|
return Object.keys(properties).filter((key) => required.has(key) && isSensitive(properties[key]));
|
package/dist/surfaces/web.d.ts
CHANGED
|
@@ -36,8 +36,8 @@ export declare const WEB_SCAFFOLD: {
|
|
|
36
36
|
readonly "@fontsource-variable/geist": "^5.3.0";
|
|
37
37
|
readonly "@tailwindcss/vite": "^4.3.3";
|
|
38
38
|
readonly "class-variance-authority": "^0.7.1";
|
|
39
|
-
readonly cn: "^0.
|
|
40
|
-
readonly "lucide-react": "^1.
|
|
39
|
+
readonly cn: "^0.4.0";
|
|
40
|
+
readonly "lucide-react": "^1.48.0";
|
|
41
41
|
readonly react: "^19.2.8";
|
|
42
42
|
readonly "react-dom": "^19.2.8";
|
|
43
43
|
readonly shadcn: "^4.21.0";
|
package/dist/surfaces/web.js
CHANGED
|
@@ -149,8 +149,8 @@ export default defineConfig({
|
|
|
149
149
|
"@fontsource-variable/geist": "^5.3.0",
|
|
150
150
|
"@tailwindcss/vite": "^4.3.3",
|
|
151
151
|
"class-variance-authority": "^0.7.1",
|
|
152
|
-
cn: "^0.
|
|
153
|
-
"lucide-react": "^1.
|
|
152
|
+
cn: "^0.4.0",
|
|
153
|
+
"lucide-react": "^1.48.0",
|
|
154
154
|
react: "^19.2.8",
|
|
155
155
|
"react-dom": "^19.2.8",
|
|
156
156
|
shadcn: "^4.21.0",
|
|
@@ -150,10 +150,17 @@ function actionStep(step, matrix) {
|
|
|
150
150
|
* (an environment gates deployments, not the checkout an untrusted PR ships).
|
|
151
151
|
*/
|
|
152
152
|
function liveJob(project) {
|
|
153
|
+
if (project.tool.tests.live?.ci === false)
|
|
154
|
+
return undefined;
|
|
153
155
|
if (liveCredentials(project).length === 0)
|
|
154
156
|
return undefined;
|
|
155
157
|
const properties = configProperties(project);
|
|
156
|
-
const secrets =
|
|
158
|
+
const secrets = [
|
|
159
|
+
...new Set([
|
|
160
|
+
...Object.keys(properties).filter((key) => isSensitive(properties[key])),
|
|
161
|
+
...liveCredentials(project),
|
|
162
|
+
]),
|
|
163
|
+
];
|
|
157
164
|
const pmName = project.packageManager ?? "npm";
|
|
158
165
|
const pm = PACKAGE_MANAGER_COMMANDS[pmName];
|
|
159
166
|
const steps = project.tool.binding === "typescript"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { describe, expect, it } from "vitest";
|
|
@@ -365,6 +365,18 @@ describe("workflows", () => {
|
|
|
365
365
|
expect(composeHermes.services.hermes.command[2]).toContain("hermes plugins doctor /work/hosts/hermes/hello_hermes --ci");
|
|
366
366
|
expect(composeHermes.services.openclaw).toBeUndefined();
|
|
367
367
|
});
|
|
368
|
+
it("supports explicit live credentials without making runtime configuration mandatory", () => {
|
|
369
|
+
const target = project(["cli"]);
|
|
370
|
+
target.tool.config = undefined;
|
|
371
|
+
target.tool.tests.live = { credentials: ["LIVE_ACCOUNT_READY"] };
|
|
372
|
+
const ci = yamlParse(emitted(target)[".github/workflows/ci.yml"]);
|
|
373
|
+
expect(ci.jobs.live.env).toEqual({ LIVE_ACCOUNT_READY: "${{ secrets.LIVE_ACCOUNT_READY }}" });
|
|
374
|
+
expect(ci.jobs.live.environment).toBe("live-tests");
|
|
375
|
+
target.tool.tests.live.ci = false;
|
|
376
|
+
const localOnly = yamlParse(emitted(target)[".github/workflows/ci.yml"]);
|
|
377
|
+
expect(localOnly.jobs.live).toBeUndefined();
|
|
378
|
+
expect(localOnly.on.workflow_dispatch).toBeUndefined();
|
|
379
|
+
});
|
|
368
380
|
it("adds the T4 live job, and workflow_dispatch, iff a config key is required and sensitive", () => {
|
|
369
381
|
const ci = yamlParse(emitted(project(["cli"]))[".github/workflows/ci.yml"]);
|
|
370
382
|
expect(ci.on.workflow_dispatch).toEqual({});
|
|
@@ -513,6 +525,10 @@ describe("bootstrap-repo", () => {
|
|
|
513
525
|
expect(release.manual.join(" ")).toContain("granular access tokens with bypass 2FA are unsupported");
|
|
514
526
|
expect(release.manual.join(" ")).toContain("NPM_TRUSTED_PUBLISHER");
|
|
515
527
|
expect(release.manual.filter((step) => step.startsWith("npm:"))).toHaveLength(1);
|
|
528
|
+
noCredential.tool.tests.live = { credentials: ["LOCAL_VAULT_READY"], ci: false };
|
|
529
|
+
const localOnly = bootstrapRepo(noCredential, { dryRun: true });
|
|
530
|
+
expect(localOnly.secrets).toEqual([]);
|
|
531
|
+
expect(localOnly.commands.some((command) => command.includes("live-tests"))).toBe(false);
|
|
516
532
|
});
|
|
517
533
|
});
|
|
518
534
|
/**
|
|
@@ -546,6 +562,53 @@ describe("gate", () => {
|
|
|
546
562
|
"openclaw end-to-end (scripted model, no LLM key)",
|
|
547
563
|
]);
|
|
548
564
|
});
|
|
565
|
+
it("packs the actual core dependency and restores the host manifest after success or failure", () => {
|
|
566
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-plugin-pack-"));
|
|
567
|
+
mkdirSync(join(root, "hosts/openclaw"), { recursive: true });
|
|
568
|
+
mkdirSync(join(root, "bin"));
|
|
569
|
+
const manifest = {
|
|
570
|
+
name: "openclaw-plugin-example",
|
|
571
|
+
dependencies: { "@example/core": "file:../.." },
|
|
572
|
+
};
|
|
573
|
+
writeFileSync(join(root, "hosts/openclaw/package.json"), JSON.stringify(manifest));
|
|
574
|
+
writeFileSync(join(root, "bin/npm"), `#!/usr/bin/env node
|
|
575
|
+
const fs = require('node:fs');
|
|
576
|
+
const path = require('node:path');
|
|
577
|
+
const args = process.argv.slice(2);
|
|
578
|
+
if (args[0] === 'pkg') {
|
|
579
|
+
const value = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
580
|
+
const [key, version] = args[2].slice('dependencies.'.length).split('=');
|
|
581
|
+
value.dependencies[key] = version;
|
|
582
|
+
fs.writeFileSync('package.json', JSON.stringify(value));
|
|
583
|
+
} else if (args[0] === 'pack') {
|
|
584
|
+
fs.copyFileSync('hosts/openclaw/package.json', 'packed.json');
|
|
585
|
+
process.exit(Number(process.env.PACK_EXIT || 0));
|
|
586
|
+
}
|
|
587
|
+
`, { mode: 0o755 });
|
|
588
|
+
const target = project(["npm", "openclaw-native"], {
|
|
589
|
+
identity: { name: "core", version: "1.2.3", description: "Example" },
|
|
590
|
+
});
|
|
591
|
+
// npmName is the publication mapping, not the Tool Factory package's name.
|
|
592
|
+
target.tool.npm = { scope: "@example" };
|
|
593
|
+
const command = packageSteps(target).find((step) => step.name === "OpenClaw plugin tarball")?.run;
|
|
594
|
+
if (!command)
|
|
595
|
+
throw new Error("OpenClaw packaging step is missing");
|
|
596
|
+
for (const code of [0, 7]) {
|
|
597
|
+
const result = spawnSync("sh", ["-c", command], {
|
|
598
|
+
cwd: root,
|
|
599
|
+
env: {
|
|
600
|
+
...process.env,
|
|
601
|
+
PATH: `${join(root, "bin")}:${process.env.PATH}`,
|
|
602
|
+
PACK_EXIT: String(code),
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
expect(result.status, result.stderr.toString()).toBe(code);
|
|
606
|
+
expect(JSON.parse(readFileSync(join(root, "packed.json"), "utf8")).dependencies).toEqual({
|
|
607
|
+
"@example/core": "1.2.3",
|
|
608
|
+
});
|
|
609
|
+
expect(JSON.parse(readFileSync(join(root, "hosts/openclaw/package.json"), "utf8"))).toEqual(manifest);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
549
612
|
it("packages one asset per selected distribution surface into dist/release", () => {
|
|
550
613
|
const target = project([
|
|
551
614
|
"npm",
|
package/dist/toolfactory/cli.js
CHANGED
|
@@ -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.
|
|
14
|
+
.version("0.4.2");
|
|
15
15
|
for (const op of operations) {
|
|
16
16
|
if (!serves(op, "cli"))
|
|
17
17
|
continue;
|
package/dist/toolfactory/mcp.js
CHANGED
|
@@ -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.
|
|
33
|
+
const server = new McpServer({ name: "toolfactory", version: "0.4.2" });
|
|
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.
|
|
3
|
+
"version": "0.4.2",
|
|
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": {
|
|
@@ -120,6 +120,19 @@ describe("typescript binding", () => {
|
|
|
120
120
|
);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
it("supports a host-vault live gate without making credentials globally required config", () => {
|
|
124
|
+
const base = project(["cli"]);
|
|
125
|
+
base.tool.tests.live = { credentials: ["LIVE_VAULT_READY"], ci: false };
|
|
126
|
+
base.operations = [{ name: "read", inputSchema: { type: "object" }, requires: [] }];
|
|
127
|
+
const live = liveTest(base)[0];
|
|
128
|
+
if (live?.kind !== "region") throw new Error("expected a region file");
|
|
129
|
+
expect(live.regions[0]?.content).toContain('const CREDENTIALS = ["LIVE_VAULT_READY"];');
|
|
130
|
+
expect(base.tool.config?.required).toBeUndefined();
|
|
131
|
+
expect(
|
|
132
|
+
ToolConfigSchema.safeParse({ ...base.tool, tests: { live: { credentials: [] } } }).success,
|
|
133
|
+
).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
123
136
|
it("emits opt-in --http and --pair flags, defaulting to stdio, on the mcp module and the cli", () => {
|
|
124
137
|
const files = [...kernel(project(["mcp", "cli"])), ...cliFiles(project(["mcp", "cli"]))];
|
|
125
138
|
const mcp = text(files, "src/toolfactory/mcp.ts");
|
package/src/commands.test.ts
CHANGED
|
@@ -251,3 +251,37 @@ describe("unpublish", () => {
|
|
|
251
251
|
expect(result.steps.some((step) => step.run.includes("npm deprecate probe@'*'"))).toBe(true);
|
|
252
252
|
});
|
|
253
253
|
});
|
|
254
|
+
|
|
255
|
+
describe("registerOpenClawProject", () => {
|
|
256
|
+
it("reports the exact register call for a tool scaffolded under the shared registry (dry-run)", () => {
|
|
257
|
+
const registry = join(mkdtempSync(join(tmpdir(), "tf-home-")), ".openclaw", "projects");
|
|
258
|
+
process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT = registry;
|
|
259
|
+
const root = join(registry, "probe");
|
|
260
|
+
const result = commands.init({
|
|
261
|
+
root,
|
|
262
|
+
name: "probe",
|
|
263
|
+
binding: "typescript",
|
|
264
|
+
setup: false,
|
|
265
|
+
git: false,
|
|
266
|
+
dryRun: true,
|
|
267
|
+
});
|
|
268
|
+
const line = result.nextSteps.find((step) => step.startsWith("OpenClaw:"));
|
|
269
|
+
expect(line).toBe(
|
|
270
|
+
"OpenClaw: run `openclaw gateway call projects.register --params " +
|
|
271
|
+
JSON.stringify({ path: root }) +
|
|
272
|
+
"` to add it to the shared project registry.",
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("says nothing outside the shared registry", () => {
|
|
277
|
+
const result = commands.init({
|
|
278
|
+
root: join(tmpdir(), "tf-plain"),
|
|
279
|
+
name: "probe",
|
|
280
|
+
binding: "typescript",
|
|
281
|
+
setup: false,
|
|
282
|
+
git: false,
|
|
283
|
+
dryRun: true,
|
|
284
|
+
});
|
|
285
|
+
expect(result.nextSteps.some((step) => step.startsWith("OpenClaw:"))).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
});
|
package/src/commands.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
|
-
import {
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
9
10
|
import { parseEnv } from "node:util";
|
|
10
11
|
import { getBinding } from "./bindings/index.js";
|
|
11
12
|
import { LIVE_TEST_COMMAND } from "./bindings/python.js";
|
|
@@ -163,6 +164,29 @@ function agentSetup(root: string): InitResult["agentConfig"] {
|
|
|
163
164
|
return { setup: result.status === 0, harnesses };
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The shared OpenClaw project registry (owner decision 2026-09-19, `docs/adoption.md`): a tool
|
|
169
|
+
* scaffolded under `~/.openclaw/projects/<id>` gets its project row at `init` time, best-effort
|
|
170
|
+
* the way `.agents/setup` is — no reachable gateway (or CLI) still leaves a scaffolded project,
|
|
171
|
+
* and the next step carries the exact call to run. Session creation stays with the calling agent.
|
|
172
|
+
* TOOLFACTORY_OPENCLAW_PROJECTS_ROOT overrides the registry root (deployments, tests).
|
|
173
|
+
*/
|
|
174
|
+
function registerOpenClawProject(root: string, dryRun: boolean): string | undefined {
|
|
175
|
+
const registryRoot =
|
|
176
|
+
process.env.TOOLFACTORY_OPENCLAW_PROJECTS_ROOT ?? join(homedir(), ".openclaw", "projects");
|
|
177
|
+
const beneath = relative(registryRoot, root);
|
|
178
|
+
if (!beneath || beneath.startsWith("..") || resolve(beneath) === beneath) return undefined;
|
|
179
|
+
const args = ["gateway", "call", "projects.register", "--params", JSON.stringify({ path: root })];
|
|
180
|
+
if (dryRun)
|
|
181
|
+
return (
|
|
182
|
+
"OpenClaw: run `openclaw " + args.join(" ") + "` to add it to the shared project registry."
|
|
183
|
+
);
|
|
184
|
+
const registered = spawnSync("openclaw", args, { timeout: 60_000 });
|
|
185
|
+
return registered.status === 0
|
|
186
|
+
? "OpenClaw: registered in the shared project registry; bind one visible dashboard session to it for ongoing work."
|
|
187
|
+
: "OpenClaw: registration did not succeed — run the `openclaw gateway call projects.register` step once the gateway is reachable.";
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
function nextSteps(project: Project, agentConfig: InitResult["agentConfig"]): string[] {
|
|
167
191
|
const cli = toolfactoryCli(project);
|
|
168
192
|
const ops =
|
|
@@ -290,10 +314,13 @@ export function init(options: InitOptions): InitResult {
|
|
|
290
314
|
git(root, [...commitIdentity(root), "commit", "-q", "--no-verify", "-m", "toolfactory init"]);
|
|
291
315
|
}
|
|
292
316
|
}
|
|
317
|
+
const steps = nextSteps(built.project, agentConfig);
|
|
318
|
+
// Last, because registration wants the committed repository on record, not a bare scaffold.
|
|
319
|
+
const openclaw = registerOpenClawProject(root, options.dryRun === true);
|
|
293
320
|
const result: InitResult = {
|
|
294
321
|
written,
|
|
295
322
|
agentConfig,
|
|
296
|
-
nextSteps:
|
|
323
|
+
nextSteps: openclaw ? [...steps, openclaw] : steps,
|
|
297
324
|
};
|
|
298
325
|
if (options.repo) {
|
|
299
326
|
const project = loadProject(root);
|
package/src/hosts/github.ts
CHANGED
|
@@ -87,13 +87,12 @@ export function bootstrapRepo(project: Project, options: BootstrapOptions = {}):
|
|
|
87
87
|
const manual = (options.manual ?? []).filter((step) => !step.startsWith("npm:"));
|
|
88
88
|
const envFile = options.envFile ?? join(project.root, ".env");
|
|
89
89
|
const values = existsSync(envFile) ? parseEnv(readFileSync(envFile, "utf8")) : {};
|
|
90
|
-
const keys = liveCredentials(project);
|
|
90
|
+
const keys = project.tool.tests.live?.ci === false ? [] : liveCredentials(project);
|
|
91
91
|
const commands: string[] = [];
|
|
92
92
|
const written: string[] = [];
|
|
93
93
|
const missing: string[] = [];
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
// config key that is both required and sensitive is the whole trigger (§6 T4).
|
|
95
|
+
// A local-vault-only live tier does not need a GitHub environment or copied account secrets.
|
|
97
96
|
if (keys.length) {
|
|
98
97
|
const ids = reviewers.map((login) => {
|
|
99
98
|
const args = ["api", `users/${login}`, "--jq", ".id"];
|
package/src/hosts/openclaw.ts
CHANGED
|
@@ -29,7 +29,8 @@ const PER_TOOL_KEYS = new Set(["name", "version", "description"]);
|
|
|
29
29
|
*/
|
|
30
30
|
function overridden(project: Project): Set<string> {
|
|
31
31
|
const openclaw = project.tool.openclaw;
|
|
32
|
-
|
|
32
|
+
// The host development dependency is deliberately pinned to the recorded scaffold release.
|
|
33
|
+
const paths = new Set<string>([".devDependencies.openclaw"]);
|
|
33
34
|
for (const section of ["dependencies", "peerDependencies", "devDependencies"] as const) {
|
|
34
35
|
for (const key of Object.keys(openclaw?.[section] ?? {})) paths.add(`.${section}.${key}`);
|
|
35
36
|
}
|
|
@@ -78,8 +79,7 @@ export function scaffoldDrift(project: Project): string[] {
|
|
|
78
79
|
// The probe must run the exact openclaw this project pins (author override or the
|
|
79
80
|
// scaffold default) - never whatever happens to be on PATH, which made the drift verdict
|
|
80
81
|
// environment-dependent: the scaffold openclawVersion is the running CLI own version.
|
|
81
|
-
const pin =
|
|
82
|
-
project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.devDependencies.openclaw;
|
|
82
|
+
const pin = project.tool.openclaw?.devDependencies?.openclaw ?? OPENCLAW_SCAFFOLD.openclawVersion;
|
|
83
83
|
const pinned = join(project.root, HOST_DIR, "node_modules/.bin/openclaw");
|
|
84
84
|
try {
|
|
85
85
|
const probe = existsSync(pinned)
|
package/src/model.ts
CHANGED
|
@@ -131,7 +131,26 @@ export const ToolConfigSchema = z
|
|
|
131
131
|
"JSON Schema 2020-12 object for the tool's configuration; mark secrets with x-toolfactory.sensitive.",
|
|
132
132
|
),
|
|
133
133
|
tests: z
|
|
134
|
-
.object({
|
|
134
|
+
.object({
|
|
135
|
+
examples: z.record(z.string(), jsonObject).default({}),
|
|
136
|
+
live: z
|
|
137
|
+
.object({
|
|
138
|
+
credentials: z
|
|
139
|
+
.array(z.string().regex(/^[A-Z_][A-Z0-9_]*$/))
|
|
140
|
+
.min(1)
|
|
141
|
+
.optional()
|
|
142
|
+
.describe(
|
|
143
|
+
"Environment gates for live tests when auth is supplied outside tool config, such as a host vault. Names only, never values.",
|
|
144
|
+
),
|
|
145
|
+
ci: z
|
|
146
|
+
.boolean()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe(
|
|
149
|
+
"Set false for live tests that require a local vault or browser unavailable on hosted CI. The native local live-test tier is still generated.",
|
|
150
|
+
),
|
|
151
|
+
})
|
|
152
|
+
.optional(),
|
|
153
|
+
})
|
|
135
154
|
.default({ examples: {} })
|
|
136
155
|
.describe("Example arguments per operation, used by the generated surface-smoke tests."),
|
|
137
156
|
npm: z.object({ scope: z.string().optional() }).optional(),
|