toolfactory 0.1.1
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 +166 -0
- package/dist/bindings/index.d.ts +23 -0
- package/dist/bindings/index.js +29 -0
- package/dist/bindings/python.d.ts +33 -0
- package/dist/bindings/python.js +805 -0
- package/dist/bindings/python.test.d.ts +1 -0
- package/dist/bindings/python.test.js +316 -0
- package/dist/bindings/typescript.d.ts +39 -0
- package/dist/bindings/typescript.js +758 -0
- package/dist/bindings/typescript.test.d.ts +1 -0
- package/dist/bindings/typescript.test.js +264 -0
- package/dist/commands.d.ts +188 -0
- package/dist/commands.js +662 -0
- package/dist/commands.test.d.ts +1 -0
- package/dist/commands.test.js +215 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +124 -0
- package/dist/hosts/browser.d.ts +10 -0
- package/dist/hosts/browser.js +94 -0
- package/dist/hosts/github.d.ts +66 -0
- package/dist/hosts/github.js +247 -0
- package/dist/hosts/openclaw.d.ts +4 -0
- package/dist/hosts/openclaw.js +94 -0
- package/dist/hosts/template.d.ts +15 -0
- package/dist/hosts/template.js +142 -0
- package/dist/hosts/web.d.ts +4 -0
- package/dist/hosts/web.js +89 -0
- package/dist/identity/name.d.ts +20 -0
- package/dist/identity/name.js +33 -0
- package/dist/identity/name.test.d.ts +1 -0
- package/dist/identity/name.test.js +37 -0
- package/dist/identity/read.d.ts +9 -0
- package/dist/identity/read.js +106 -0
- package/dist/identity/read.test.d.ts +1 -0
- package/dist/identity/read.test.js +76 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/introspect/index.d.ts +15 -0
- package/dist/introspect/index.js +63 -0
- package/dist/model.d.ts +236 -0
- package/dist/model.js +214 -0
- package/dist/ops.d.ts +255 -0
- package/dist/ops.js +297 -0
- package/dist/project/apply.d.ts +35 -0
- package/dist/project/apply.js +360 -0
- package/dist/project/apply.test.d.ts +1 -0
- package/dist/project/apply.test.js +285 -0
- package/dist/project/gate.d.ts +129 -0
- package/dist/project/gate.js +597 -0
- package/dist/project/history.d.ts +62 -0
- package/dist/project/history.js +41 -0
- package/dist/project/load.d.ts +21 -0
- package/dist/project/load.js +82 -0
- package/dist/project/lock.d.ts +28 -0
- package/dist/project/lock.js +42 -0
- package/dist/project/plan.d.ts +5 -0
- package/dist/project/plan.js +96 -0
- package/dist/report/coverage.d.ts +22 -0
- package/dist/report/coverage.js +72 -0
- package/dist/report/coverage.test.d.ts +1 -0
- package/dist/report/coverage.test.js +93 -0
- package/dist/surfaces/agent-plugins.d.ts +10 -0
- package/dist/surfaces/agent-plugins.js +36 -0
- package/dist/surfaces/agents.d.ts +35 -0
- package/dist/surfaces/agents.js +609 -0
- package/dist/surfaces/agents.template.d.ts +8 -0
- package/dist/surfaces/agents.template.js +21 -0
- package/dist/surfaces/agents.test.d.ts +1 -0
- package/dist/surfaces/agents.test.js +181 -0
- package/dist/surfaces/browser-extension.d.ts +101 -0
- package/dist/surfaces/browser-extension.js +1034 -0
- package/dist/surfaces/browser-extension.test.d.ts +1 -0
- package/dist/surfaces/browser-extension.test.js +145 -0
- package/dist/surfaces/claude.d.ts +2 -0
- package/dist/surfaces/claude.js +81 -0
- package/dist/surfaces/codex.d.ts +13 -0
- package/dist/surfaces/codex.js +101 -0
- package/dist/surfaces/codex.test.d.ts +1 -0
- package/dist/surfaces/codex.test.js +67 -0
- package/dist/surfaces/cursor.d.ts +2 -0
- package/dist/surfaces/cursor.js +50 -0
- package/dist/surfaces/distribution.test.d.ts +1 -0
- package/dist/surfaces/distribution.test.js +146 -0
- package/dist/surfaces/dsh.d.ts +25 -0
- package/dist/surfaces/dsh.js +147 -0
- package/dist/surfaces/dsh.test.d.ts +1 -0
- package/dist/surfaces/dsh.test.js +100 -0
- package/dist/surfaces/external.d.ts +9 -0
- package/dist/surfaces/external.js +4 -0
- package/dist/surfaces/gemini.d.ts +29 -0
- package/dist/surfaces/gemini.js +68 -0
- package/dist/surfaces/gemini.test.d.ts +1 -0
- package/dist/surfaces/gemini.test.js +74 -0
- package/dist/surfaces/hermes-native.d.ts +53 -0
- package/dist/surfaces/hermes-native.js +507 -0
- package/dist/surfaces/hermes-native.test.d.ts +1 -0
- package/dist/surfaces/hermes-native.test.js +156 -0
- package/dist/surfaces/kernel.d.ts +5 -0
- package/dist/surfaces/kernel.js +63 -0
- package/dist/surfaces/mcp-registry.d.ts +19 -0
- package/dist/surfaces/mcp-registry.js +160 -0
- package/dist/surfaces/mcp-registry.test.d.ts +1 -0
- package/dist/surfaces/mcp-registry.test.js +79 -0
- package/dist/surfaces/mcpb.d.ts +11 -0
- package/dist/surfaces/mcpb.js +118 -0
- package/dist/surfaces/mcpb.test.d.ts +1 -0
- package/dist/surfaces/mcpb.test.js +104 -0
- package/dist/surfaces/npm.d.ts +6 -0
- package/dist/surfaces/npm.js +72 -0
- package/dist/surfaces/openclaw-native.d.ts +93 -0
- package/dist/surfaces/openclaw-native.js +956 -0
- package/dist/surfaces/openclaw-native.test.d.ts +1 -0
- package/dist/surfaces/openclaw-native.test.js +246 -0
- package/dist/surfaces/pypi.d.ts +5 -0
- package/dist/surfaces/pypi.js +82 -0
- package/dist/surfaces/readme.d.ts +7 -0
- package/dist/surfaces/readme.js +181 -0
- package/dist/surfaces/registry.d.ts +6 -0
- package/dist/surfaces/registry.js +73 -0
- package/dist/surfaces/shared.d.ts +49 -0
- package/dist/surfaces/shared.js +112 -0
- package/dist/surfaces/skill.d.ts +7 -0
- package/dist/surfaces/skill.js +118 -0
- package/dist/surfaces/skill.test.d.ts +1 -0
- package/dist/surfaces/skill.test.js +87 -0
- package/dist/surfaces/web.d.ts +97 -0
- package/dist/surfaces/web.js +1285 -0
- package/dist/surfaces/web.test.d.ts +1 -0
- package/dist/surfaces/web.test.js +255 -0
- package/dist/surfaces/workflows.d.ts +2 -0
- package/dist/surfaces/workflows.js +809 -0
- package/dist/surfaces/workflows.test.d.ts +1 -0
- package/dist/surfaces/workflows.test.js +563 -0
- package/dist/toolfactory/cli.d.ts +2 -0
- package/dist/toolfactory/cli.js +65 -0
- package/dist/toolfactory/config.d.ts +3 -0
- package/dist/toolfactory/config.js +27 -0
- package/dist/toolfactory/mcp.d.ts +37 -0
- package/dist/toolfactory/mcp.js +269 -0
- package/dist/toolfactory/types.d.ts +31 -0
- package/dist/toolfactory/types.js +10 -0
- package/dist/toolfactory/web.d.ts +4 -0
- package/dist/toolfactory/web.js +40 -0
- package/dist/validate/agent-plugins.d.ts +5 -0
- package/dist/validate/agent-plugins.js +46 -0
- package/package.json +68 -0
- package/schemas/agent-plugins/mcp.schema.json +120 -0
- package/schemas/agent-plugins/plugin.schema.json +65 -0
- package/src/bindings/index.ts +47 -0
- package/src/bindings/python.test.ts +374 -0
- package/src/bindings/python.ts +862 -0
- package/src/bindings/typescript.test.ts +329 -0
- package/src/bindings/typescript.ts +818 -0
- package/src/commands.test.ts +253 -0
- package/src/commands.ts +886 -0
- package/src/e2e.test.ts +169 -0
- package/src/hosts/browser.ts +106 -0
- package/src/hosts/github.ts +321 -0
- package/src/hosts/openclaw.ts +110 -0
- package/src/hosts/template.ts +149 -0
- package/src/hosts/web.ts +111 -0
- package/src/identity/name.test.ts +43 -0
- package/src/identity/name.ts +39 -0
- package/src/identity/read.test.ts +92 -0
- package/src/identity/read.ts +119 -0
- package/src/index.ts +11 -0
- package/src/introspect/index.ts +72 -0
- package/src/model.ts +386 -0
- package/src/ops.ts +333 -0
- package/src/project/apply.test.ts +348 -0
- package/src/project/apply.ts +417 -0
- package/src/project/gate.ts +717 -0
- package/src/project/history.ts +46 -0
- package/src/project/load.ts +104 -0
- package/src/project/lock.ts +54 -0
- package/src/project/plan.ts +101 -0
- package/src/report/coverage.test.ts +108 -0
- package/src/report/coverage.ts +113 -0
- package/src/surfaces/agent-plugins.ts +47 -0
- package/src/surfaces/agents.template.ts +32 -0
- package/src/surfaces/agents.test.ts +206 -0
- package/src/surfaces/agents.ts +729 -0
- package/src/surfaces/browser-extension.test.ts +175 -0
- package/src/surfaces/browser-extension.ts +1095 -0
- package/src/surfaces/claude.ts +99 -0
- package/src/surfaces/codex.test.ts +71 -0
- package/src/surfaces/codex.ts +105 -0
- package/src/surfaces/cursor.ts +62 -0
- package/src/surfaces/distribution.test.ts +166 -0
- package/src/surfaces/dsh.test.ts +110 -0
- package/src/surfaces/dsh.ts +159 -0
- package/src/surfaces/external.ts +13 -0
- package/src/surfaces/gemini.test.ts +80 -0
- package/src/surfaces/gemini.ts +84 -0
- package/src/surfaces/hermes-native.test.ts +179 -0
- package/src/surfaces/hermes-native.ts +584 -0
- package/src/surfaces/kernel.ts +68 -0
- package/src/surfaces/mcp-registry.test.ts +99 -0
- package/src/surfaces/mcp-registry.ts +190 -0
- package/src/surfaces/mcpb.test.ts +112 -0
- package/src/surfaces/mcpb.ts +130 -0
- package/src/surfaces/npm.ts +78 -0
- package/src/surfaces/openclaw-native.test.ts +283 -0
- package/src/surfaces/openclaw-native.ts +1070 -0
- package/src/surfaces/pypi.ts +84 -0
- package/src/surfaces/readme.ts +218 -0
- package/src/surfaces/registry.ts +79 -0
- package/src/surfaces/shared.ts +140 -0
- package/src/surfaces/skill.test.ts +103 -0
- package/src/surfaces/skill.ts +136 -0
- package/src/surfaces/web.test.ts +303 -0
- package/src/surfaces/web.ts +1310 -0
- package/src/surfaces/workflows.test.ts +677 -0
- package/src/surfaces/workflows.ts +936 -0
- package/src/toolfactory/cli.ts +74 -0
- package/src/toolfactory/config.ts +33 -0
- package/src/toolfactory/mcp.ts +311 -0
- package/src/toolfactory/types.ts +52 -0
- package/src/toolfactory/web.ts +45 -0
- package/src/validate/agent-plugins.ts +53 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-CCXhQKK3.css +2 -0
- package/web/dist/assets/index-D1PwZ3qS.js +10 -0
- package/web/dist/index.html +14 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command layer. Every toolfactory verb is a function here so the CLI and the MCP
|
|
3
|
+
* surface expose exactly the same operations.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { parseEnv } from "node:util";
|
|
10
|
+
import { getBinding } from "./bindings/index.js";
|
|
11
|
+
import { LIVE_TEST_COMMAND } from "./bindings/python.js";
|
|
12
|
+
import {
|
|
13
|
+
type BootstrapResult,
|
|
14
|
+
type CreateRepoResult,
|
|
15
|
+
createRepo,
|
|
16
|
+
githubSlug,
|
|
17
|
+
LIVE_ENVIRONMENT,
|
|
18
|
+
bootstrapRepo as prepareRepository,
|
|
19
|
+
} from "./hosts/github.js";
|
|
20
|
+
import { assertValidName, projectName } from "./identity/name.js";
|
|
21
|
+
import { introspect as runIntrospect, snapshot } from "./introspect/index.js";
|
|
22
|
+
import type {
|
|
23
|
+
Binding,
|
|
24
|
+
Command,
|
|
25
|
+
Identity,
|
|
26
|
+
PlannedFile,
|
|
27
|
+
Project,
|
|
28
|
+
SurfaceId,
|
|
29
|
+
ToolConfig,
|
|
30
|
+
} from "./model.js";
|
|
31
|
+
import {
|
|
32
|
+
assertNoSensitiveArgument,
|
|
33
|
+
defaultSurfaces,
|
|
34
|
+
isInstructionOnly,
|
|
35
|
+
SURFACE_IDS,
|
|
36
|
+
ToolConfigSchema,
|
|
37
|
+
} from "./model.js";
|
|
38
|
+
import { apply, check as checkPlan, type Drift, setState } from "./project/apply.js";
|
|
39
|
+
import {
|
|
40
|
+
type GateStep,
|
|
41
|
+
gateSteps,
|
|
42
|
+
manualSteps,
|
|
43
|
+
PACKAGE_MANAGER_COMMANDS,
|
|
44
|
+
packageSteps,
|
|
45
|
+
type Registry,
|
|
46
|
+
registries,
|
|
47
|
+
toolfactoryCli,
|
|
48
|
+
unpublishSteps,
|
|
49
|
+
} from "./project/gate.js";
|
|
50
|
+
import { droppedSurfaces, previousTag, toolAtRef } from "./project/history.js";
|
|
51
|
+
import {
|
|
52
|
+
loadProject,
|
|
53
|
+
OPS_PATH,
|
|
54
|
+
TOOL_PATH,
|
|
55
|
+
TOOLFACTORY_VERSION,
|
|
56
|
+
toOperation,
|
|
57
|
+
} from "./project/load.js";
|
|
58
|
+
import { readLock } from "./project/lock.js";
|
|
59
|
+
import { buildPlan, TOOL_SCHEMA_PATH } from "./project/plan.js";
|
|
60
|
+
import { type Coverage, computeCoverage } from "./report/coverage.js";
|
|
61
|
+
import { PLUGIN_SCHEMA_ID } from "./surfaces/agent-plugins.js";
|
|
62
|
+
import { reloadLine, SETUP_PATH } from "./surfaces/agents.js";
|
|
63
|
+
import { HOST_DIR as BROWSER_HOST_DIR } from "./surfaces/browser-extension.js";
|
|
64
|
+
import { assertSurfaceRequirements, getSurface, selectedSurfaces } from "./surfaces/registry.js";
|
|
65
|
+
import {
|
|
66
|
+
compact,
|
|
67
|
+
configProperties,
|
|
68
|
+
envName,
|
|
69
|
+
has,
|
|
70
|
+
isSensitive,
|
|
71
|
+
json,
|
|
72
|
+
liveCredentials,
|
|
73
|
+
requiredConfig,
|
|
74
|
+
} from "./surfaces/shared.js";
|
|
75
|
+
import { validateAgentPlugin } from "./validate/agent-plugins.js";
|
|
76
|
+
|
|
77
|
+
export interface InitOptions {
|
|
78
|
+
root: string;
|
|
79
|
+
name: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
binding: Binding;
|
|
82
|
+
/** `none` creates an instruction-only skill/Codex plugin with no executable payload. */
|
|
83
|
+
runtime?: "kernel" | "none";
|
|
84
|
+
/** Default: `defaultSurfaces(binding)`, the skills-first minimum. */
|
|
85
|
+
surfaces?: SurfaceId[];
|
|
86
|
+
license?: string;
|
|
87
|
+
repository?: string;
|
|
88
|
+
author?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Activation triggers for hosts that key off them (Kiro Powers, Agent Plugins); defaults to
|
|
91
|
+
* `[name]`.
|
|
92
|
+
*/
|
|
93
|
+
keywords?: string[];
|
|
94
|
+
/** `git init` when the directory is not a repository yet. `.agents/setup` needs one. */
|
|
95
|
+
git?: boolean;
|
|
96
|
+
/** Run `.agents/setup`: harness adapters, the git hooks, the dependency install. */
|
|
97
|
+
setup?: boolean;
|
|
98
|
+
/** `owner/name` of a GitHub repository to create with `gh` and push to. */
|
|
99
|
+
repo?: string;
|
|
100
|
+
/** Create that repository public; the default is private. */
|
|
101
|
+
public?: boolean;
|
|
102
|
+
/** Print the `gh` invocations instead of running them. */
|
|
103
|
+
dryRun?: boolean;
|
|
104
|
+
/** GitHub logins that must approve a live run, when the repository gets a live tier. */
|
|
105
|
+
reviewers?: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface InitResult {
|
|
109
|
+
written: string[];
|
|
110
|
+
/** What `.agents/setup` did here: whether it succeeded, and the harnesses it rendered for. */
|
|
111
|
+
agentConfig: { setup: boolean; harnesses: string[] };
|
|
112
|
+
repository?: CreateRepoResult & { secrets: string[] };
|
|
113
|
+
/** What the caller — a human or the agent that ran `init` — has to do next, in order. */
|
|
114
|
+
nextSteps: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const BUNDLE_SURFACES: SurfaceId[] = ["agent-plugins", "skill", "claude", "codex", "cursor"];
|
|
118
|
+
|
|
119
|
+
function writeIfAbsent(root: string, files: PlannedFile[]): string[] {
|
|
120
|
+
const written: string[] = [];
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
if (file.kind !== "file") continue;
|
|
123
|
+
const path = join(root, file.path);
|
|
124
|
+
if (existsSync(path)) continue;
|
|
125
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
126
|
+
writeFileSync(path, file.content);
|
|
127
|
+
written.push(file.path);
|
|
128
|
+
}
|
|
129
|
+
return written;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function git(root: string, args: string[]): boolean {
|
|
133
|
+
return spawnSync("git", args, { cwd: root, stdio: "ignore", timeout: 60_000 }).status === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `git commit` refuses to run without an identity, and a CI runner or a fresh machine has none;
|
|
138
|
+
* this commit is toolfactory's own, so it falls back to naming itself rather than failing.
|
|
139
|
+
*/
|
|
140
|
+
function commitIdentity(root: string): string[] {
|
|
141
|
+
return git(root, ["config", "--get", "user.email"])
|
|
142
|
+
? []
|
|
143
|
+
: ["-c", "user.name=toolfactory", "-c", "user.email=toolfactory@localhost"];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `.agents/setup`: the template's one entry point — it renders the harness adapters from
|
|
148
|
+
* `.agents/`, installs the git hooks that keep them in sync (and the drift gate in front of the
|
|
149
|
+
* pre-commit hook), and installs the project's dependencies. Best-effort: a machine missing
|
|
150
|
+
* python3, pnpm or the network still gets a scaffolded project, and `nextSteps` says to run it.
|
|
151
|
+
*/
|
|
152
|
+
function agentSetup(root: string): InitResult["agentConfig"] {
|
|
153
|
+
const result = spawnSync("bash", [SETUP_PATH], {
|
|
154
|
+
cwd: root,
|
|
155
|
+
encoding: "utf8",
|
|
156
|
+
timeout: 300_000,
|
|
157
|
+
});
|
|
158
|
+
const harnesses = (result.stdout ?? "")
|
|
159
|
+
.split("\n")
|
|
160
|
+
.flatMap((line) => line.match(/^Generated ([^:]+):/)?.slice(1, 2) ?? []);
|
|
161
|
+
return { setup: result.status === 0, harnesses };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function nextSteps(project: Project, agentConfig: InitResult["agentConfig"]): string[] {
|
|
165
|
+
const cli = toolfactoryCli(project);
|
|
166
|
+
const ops =
|
|
167
|
+
project.tool.binding === "typescript"
|
|
168
|
+
? "src/ops.ts"
|
|
169
|
+
: `src/${projectName.pythonPackage(project.identity.name)}/ops.py`;
|
|
170
|
+
// The credential inventory, once, where the agent that ran `init` will read it: a project that
|
|
171
|
+
// declares none says nothing at all.
|
|
172
|
+
const summary = secretsSummary(secretsReport(project));
|
|
173
|
+
return [
|
|
174
|
+
agentConfig.setup
|
|
175
|
+
? `Agent config is wired: \`.agents/\` is the canon (\`skills/\`, \`mcp/servers.json\`). \`${SETUP_PATH}\` rendered ${agentConfig.harnesses.length} harness adapter(s) here (${agentConfig.harnesses.join(", ") || "none detected"}) and installed the pre-commit/post-checkout/post-merge hooks, so pulls, branch switches and commits re-sync on their own. Details: \`.agents/README.md\`.`
|
|
176
|
+
: `Agent config is written but \`${SETUP_PATH}\` did not finish here: run \`bash ${SETUP_PATH}\` to render the harness adapters from \`.agents/\`, install the git hooks that keep them in sync, and install the dependencies. Details: \`.agents/README.md\`.`,
|
|
177
|
+
reloadLine(process.env),
|
|
178
|
+
isInstructionOnly(project.tool)
|
|
179
|
+
? `Next: write the skill instructions in \`skills/${project.identity.name}/SKILL.md\`, then \`${cli} build\`.`
|
|
180
|
+
: `Next: write your operations in \`${ops}\`, then \`${cli} introspect && ${cli} build\`.`,
|
|
181
|
+
...(summary ? [summary] : []),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create dev.toolfactory/tool.json, the identity file and the binding's scaffold, build every
|
|
187
|
+
* selected surface, and leave a project that is already live in the harness it was run from:
|
|
188
|
+
* a git repository, the first-party skills, the rendered agent config, and — only when asked —
|
|
189
|
+
* a GitHub repository holding it.
|
|
190
|
+
*/
|
|
191
|
+
export function init(options: InitOptions): InitResult {
|
|
192
|
+
const root = resolve(options.root);
|
|
193
|
+
assertValidName(options.name);
|
|
194
|
+
mkdirSync(root, { recursive: true });
|
|
195
|
+
// First, because `.agents/setup` starts with `git rev-parse --show-toplevel` and fails outside
|
|
196
|
+
// a repository, and because `gh repo create --source` pushes one.
|
|
197
|
+
if (options.git !== false && !existsSync(join(root, ".git")))
|
|
198
|
+
git(root, ["init", "-q", "-b", "main"]);
|
|
199
|
+
const surfaces = [...new Set(options.surfaces ?? defaultSurfaces(options.binding))];
|
|
200
|
+
assertSurfaceRequirements(surfaces);
|
|
201
|
+
const usesBundle = surfaces.some((s) => BUNDLE_SURFACES.includes(s));
|
|
202
|
+
const identityPath = usesBundle
|
|
203
|
+
? "plugin.json"
|
|
204
|
+
: options.binding === "python"
|
|
205
|
+
? "pyproject.toml"
|
|
206
|
+
: "package.json";
|
|
207
|
+
const identity: Identity = compact({
|
|
208
|
+
name: options.name,
|
|
209
|
+
version: "0.1.0",
|
|
210
|
+
description: options.description,
|
|
211
|
+
license: options.license,
|
|
212
|
+
repository: options.repository,
|
|
213
|
+
author: options.author ? { name: options.author } : undefined,
|
|
214
|
+
keywords: options.keywords?.length ? options.keywords : [options.name],
|
|
215
|
+
}) as Identity;
|
|
216
|
+
const written: string[] = [];
|
|
217
|
+
if (usesBundle && !existsSync(join(root, "plugin.json"))) {
|
|
218
|
+
mkdirSync(root, { recursive: true });
|
|
219
|
+
writeFileSync(join(root, "plugin.json"), json({ $schema: PLUGIN_SCHEMA_ID, ...identity }));
|
|
220
|
+
written.push("plugin.json");
|
|
221
|
+
}
|
|
222
|
+
const tool = ToolConfigSchema.parse({
|
|
223
|
+
$schema: `./${TOOL_SCHEMA_PATH.split("/").pop()}`,
|
|
224
|
+
schemaVersion: 1,
|
|
225
|
+
identity: identityPath,
|
|
226
|
+
binding: options.binding,
|
|
227
|
+
...(options.runtime ? { runtime: options.runtime } : {}),
|
|
228
|
+
surfaces,
|
|
229
|
+
});
|
|
230
|
+
const toolPath = join(root, TOOL_PATH);
|
|
231
|
+
if (!existsSync(toolPath)) {
|
|
232
|
+
mkdirSync(dirname(toolPath), { recursive: true });
|
|
233
|
+
writeFileSync(toolPath, json(tool));
|
|
234
|
+
written.push(TOOL_PATH);
|
|
235
|
+
}
|
|
236
|
+
const scaffoldProject: Project = {
|
|
237
|
+
root,
|
|
238
|
+
tool,
|
|
239
|
+
identity,
|
|
240
|
+
identityExtra: {},
|
|
241
|
+
operations: [],
|
|
242
|
+
toolfactoryVersion: TOOLFACTORY_VERSION,
|
|
243
|
+
};
|
|
244
|
+
if (!isInstructionOnly(tool))
|
|
245
|
+
written.push(...writeIfAbsent(root, getBinding(options.binding).scaffold(scaffoldProject)));
|
|
246
|
+
// A region file that predates toolfactory has no markers, and `apply` refuses to guess where
|
|
247
|
+
// they go; appending the empty pair lets `init` run in a folder that already has these files.
|
|
248
|
+
for (const file of ["agents", "readme"].flatMap((id) =>
|
|
249
|
+
getSurface(id as SurfaceId).plan(scaffoldProject),
|
|
250
|
+
)) {
|
|
251
|
+
if (file.kind !== "region") continue;
|
|
252
|
+
const path = join(root, file.path);
|
|
253
|
+
if (!existsSync(path)) continue;
|
|
254
|
+
const text = readFileSync(path, "utf8");
|
|
255
|
+
const missing = file.regions.filter((region) => !text.includes(region.begin));
|
|
256
|
+
if (missing.length)
|
|
257
|
+
writeFileSync(
|
|
258
|
+
path,
|
|
259
|
+
`${text}\n${missing.map((region) => `${region.begin}\n${region.end}`).join("\n")}\n`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
// The kernel files exist from the first moment so `introspect` can spawn the server.
|
|
263
|
+
const built = build(root);
|
|
264
|
+
written.push(...built.result.written);
|
|
265
|
+
// Best-effort: the first-party skills for the selected surfaces, so an agent developing the
|
|
266
|
+
// tool from inside a harness that reads them has them from the first `init`.
|
|
267
|
+
const skills = [
|
|
268
|
+
surfaces.includes("web") ? "shadcn/ui@shadcn" : undefined,
|
|
269
|
+
surfaces.some((s) => s === "mcp" || s === "openclaw-native" || s === "hermes-native")
|
|
270
|
+
? "anthropics/skills@mcp-builder"
|
|
271
|
+
: undefined,
|
|
272
|
+
].filter((spec): spec is string => spec !== undefined);
|
|
273
|
+
for (const spec of skills) {
|
|
274
|
+
spawnSync("npx", ["--yes", "skills", "add", spec, "-y"], {
|
|
275
|
+
cwd: root,
|
|
276
|
+
stdio: "ignore",
|
|
277
|
+
timeout: 120_000,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const agentConfig = options.setup === false ? { setup: false, harnesses: [] } : agentSetup(root);
|
|
281
|
+
// A repository with no commit has nothing to push and no baseline to diff against; one that
|
|
282
|
+
// already has history is the author's, and init adds nothing to it. `--no-verify`: the
|
|
283
|
+
// pre-commit hook `.agents/setup` just installed converges agent config and re-runs the drift
|
|
284
|
+
// gate, which this build satisfied by construction, and requiring it here would make the first
|
|
285
|
+
// commit depend on a toolchain the project has not installed yet.
|
|
286
|
+
if (options.git !== false && !git(root, ["rev-parse", "--verify", "-q", "HEAD"])) {
|
|
287
|
+
if (git(root, ["add", "-A"])) {
|
|
288
|
+
git(root, [...commitIdentity(root), "commit", "-q", "--no-verify", "-m", "toolfactory init"]);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const result: InitResult = {
|
|
292
|
+
written,
|
|
293
|
+
agentConfig,
|
|
294
|
+
nextSteps: nextSteps(built.project, agentConfig),
|
|
295
|
+
};
|
|
296
|
+
if (options.repo) {
|
|
297
|
+
const project = loadProject(root);
|
|
298
|
+
const created = createRepo(project, {
|
|
299
|
+
slug: options.repo,
|
|
300
|
+
public: options.public,
|
|
301
|
+
dryRun: options.dryRun,
|
|
302
|
+
});
|
|
303
|
+
// Everything GitHub-only in one call: it creates the live environment only when the project
|
|
304
|
+
// has a live tier, and otherwise still sets the release secrets .env already holds.
|
|
305
|
+
const prepared = prepareRepository(project, {
|
|
306
|
+
repository: options.repo,
|
|
307
|
+
reviewers: options.reviewers,
|
|
308
|
+
dryRun: options.dryRun,
|
|
309
|
+
releaseSecrets: registries(project).flatMap((row) => row.secrets),
|
|
310
|
+
manual: manualSteps(project),
|
|
311
|
+
});
|
|
312
|
+
result.repository = {
|
|
313
|
+
...created,
|
|
314
|
+
commands: [...created.commands, ...prepared.commands],
|
|
315
|
+
secrets: prepared.secrets,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function build(root = ".") {
|
|
322
|
+
const project = loadProject(root);
|
|
323
|
+
return {
|
|
324
|
+
project,
|
|
325
|
+
result: {
|
|
326
|
+
...apply(project.root, buildPlan(project), TOOLFACTORY_VERSION),
|
|
327
|
+
nudge: unpublishNudge(project),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Throws when any generated file drifted, so CLI and MCP callers see a failure. */
|
|
333
|
+
/** The drift gate: the operation snapshot and every generated file must match the code. */
|
|
334
|
+
export async function check(root = "."): Promise<{ project: Project; drift: Drift[] }> {
|
|
335
|
+
const project = loadProject(root);
|
|
336
|
+
assertNoSensitiveArgument(project.tool.config, project.operations);
|
|
337
|
+
const drift = checkPlan(project.root, buildPlan(project), TOOLFACTORY_VERSION);
|
|
338
|
+
const ops = isInstructionOnly(project.tool) ? undefined : await snapshot(project);
|
|
339
|
+
if (ops?.changed) drift.unshift({ kind: "changed", path: OPS_PATH });
|
|
340
|
+
if (drift.length) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`${drift.length} generated file(s) out of date; run \`toolfactory ${ops?.changed ? "introspect" : "build"}\`:\n${drift.map((d) => ` ${d.kind}: ${d.path}`).join("\n")}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return { project, drift };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Regenerate the kernel first so the snapshot always reflects the current templates. */
|
|
349
|
+
export async function introspect(root = ".") {
|
|
350
|
+
const { project } = build(root);
|
|
351
|
+
if (isInstructionOnly(project.tool))
|
|
352
|
+
throw new Error(
|
|
353
|
+
'runtime "none" has no kernel to introspect; edit SKILL.md and run `toolfactory build`.',
|
|
354
|
+
);
|
|
355
|
+
const snapshot = await runIntrospect(project);
|
|
356
|
+
// Against the operations the kernel just reported, not the ones on disk: this is the moment an
|
|
357
|
+
// author who routed a secret through an argument finds out, before it reaches any surface.
|
|
358
|
+
assertNoSensitiveArgument(project.tool.config, snapshot.ops.tools.map(toOperation));
|
|
359
|
+
if (snapshot.changed) build(root);
|
|
360
|
+
return snapshot;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function coverage(root = "."): Coverage {
|
|
364
|
+
const project = loadProject(root);
|
|
365
|
+
return computeCoverage(project, selectedSurfaces(project.tool.surfaces));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface ValidationOutcome {
|
|
369
|
+
label: string;
|
|
370
|
+
command: string;
|
|
371
|
+
ok: boolean;
|
|
372
|
+
output: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Run every selected surface's upstream validator (tier 1 and the kernel smoke); throws if any fails. */
|
|
376
|
+
export function validate(root = ".", only?: SurfaceId): ValidationOutcome[] {
|
|
377
|
+
const project = loadProject(root);
|
|
378
|
+
const surfaces = selectedSurfaces(project.tool.surfaces).filter((s) => !only || s.id === only);
|
|
379
|
+
const outcomes: ValidationOutcome[] = [];
|
|
380
|
+
for (const surface of surfaces) {
|
|
381
|
+
if (surface.id === "agent-plugins") {
|
|
382
|
+
const problems = validateAgentPlugin(project.root);
|
|
383
|
+
outcomes.push({
|
|
384
|
+
label: "agent-plugins schema",
|
|
385
|
+
command: "ajv (vendored Agent Plugins 1.0.0 schemas)",
|
|
386
|
+
ok: problems.length === 0,
|
|
387
|
+
output: problems.map((p) => `${p.file}: ${p.message}`).join("\n"),
|
|
388
|
+
});
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
for (const command of surface.validate?.(project) ?? []) {
|
|
392
|
+
outcomes.push(run(command));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const failed = outcomes.filter((outcome) => !outcome.ok);
|
|
396
|
+
if (failed.length) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`${failed.length} validator(s) failed:\n${failed
|
|
399
|
+
.map(
|
|
400
|
+
(f) =>
|
|
401
|
+
` ${f.label}: ${f.command}\n${f.output
|
|
402
|
+
.split("\n")
|
|
403
|
+
.map((l) => ` ${l}`)
|
|
404
|
+
.join("\n")}`,
|
|
405
|
+
)
|
|
406
|
+
.join("\n")}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return outcomes;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function run(command: Command): ValidationOutcome {
|
|
413
|
+
const result = spawnSync(command.command, command.args, {
|
|
414
|
+
cwd: command.cwd,
|
|
415
|
+
env: { ...process.env, ...command.env },
|
|
416
|
+
encoding: "utf8",
|
|
417
|
+
timeout: 300_000,
|
|
418
|
+
});
|
|
419
|
+
const output = [result.stdout, result.stderr, result.error?.message]
|
|
420
|
+
.filter(Boolean)
|
|
421
|
+
.join("\n")
|
|
422
|
+
.trim();
|
|
423
|
+
return {
|
|
424
|
+
label: command.label,
|
|
425
|
+
command: [command.command, ...command.args].join(" "),
|
|
426
|
+
ok: result.status === 0,
|
|
427
|
+
output,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface StepResult {
|
|
432
|
+
name: string;
|
|
433
|
+
ok: boolean;
|
|
434
|
+
durationMs: number;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Run a step list from `project/gate.ts` in order, stopping at the first failure. `when: "ci"`
|
|
439
|
+
* steps are runner provisioning — a global install no checkout may perform on the author's
|
|
440
|
+
* machine — and are skipped here exactly as `renderShell` skips them. Every step's output goes to
|
|
441
|
+
* this process's stderr, never its stdout, so running the gate over the MCP surface cannot
|
|
442
|
+
* corrupt the protocol stream.
|
|
443
|
+
*/
|
|
444
|
+
export function runSteps(root: string, steps: GateStep[]): { steps: StepResult[] } {
|
|
445
|
+
const cwd = resolve(root);
|
|
446
|
+
const results: StepResult[] = [];
|
|
447
|
+
for (const step of steps) {
|
|
448
|
+
if (step.when === "ci") continue;
|
|
449
|
+
const started = Date.now();
|
|
450
|
+
const { status } = spawnSync(step.run, {
|
|
451
|
+
cwd,
|
|
452
|
+
shell: true,
|
|
453
|
+
stdio: ["ignore", 2, 2],
|
|
454
|
+
env: { ...process.env, ...step.env },
|
|
455
|
+
timeout: 1_800_000,
|
|
456
|
+
});
|
|
457
|
+
results.push({ name: step.name, ok: status === 0, durationMs: Date.now() - started });
|
|
458
|
+
if (status !== 0) break;
|
|
459
|
+
}
|
|
460
|
+
return { steps: results };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function green(label: string, result: { steps: StepResult[] }): { steps: StepResult[] } {
|
|
464
|
+
const failed = result.steps.find((step) => !step.ok);
|
|
465
|
+
if (failed) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`${label} failed at step "${failed.name}" after ${failed.durationMs} ms; its output is above.`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The gate: what "green" means for this project, run here instead of on a runner. `ci.yml`
|
|
475
|
+
* renders the same list, so a project with no CI at all — no GitHub, plain git or none — has the
|
|
476
|
+
* identical gate one command away.
|
|
477
|
+
*/
|
|
478
|
+
export function gate(root = "."): { steps: StepResult[] } {
|
|
479
|
+
return green("gate", runSteps(root, gateSteps(loadProject(root))));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Every release asset into `dist/release/`, by the same steps the release workflow's job runs. */
|
|
483
|
+
export function packageRelease(root = "."): { steps: StepResult[] } {
|
|
484
|
+
return green("package", runSteps(root, packageSteps(loadProject(root))));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------------------------------------------------------------------------------------------
|
|
488
|
+
// secrets — the credential inventory (§6). Never a value: no `set`, no `value` property, on any
|
|
489
|
+
// surface. A secret reaches the kernel through the environment a host injects, and it gets there
|
|
490
|
+
// through that host's own masked field, `.env`, or `bootstrap-repo`.
|
|
491
|
+
// ---------------------------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
export interface SecretRow {
|
|
494
|
+
/** The environment variable name, on every surface and in every environment. */
|
|
495
|
+
name: string;
|
|
496
|
+
kind: "config" | "release";
|
|
497
|
+
required: boolean;
|
|
498
|
+
/** Selected surfaces (a config key) or registry ids (a release token) that consume it. */
|
|
499
|
+
needs: string[];
|
|
500
|
+
/** Present in `<root>/.env` or this process's environment. Presence only, never the value. */
|
|
501
|
+
local: boolean;
|
|
502
|
+
/** Named by `gh secret list`; absent when `gh` is not on PATH or no repository is declared. */
|
|
503
|
+
github?: boolean;
|
|
504
|
+
url?: string;
|
|
505
|
+
/** The exact next action, per selected host and per registry. */
|
|
506
|
+
howTo: string[];
|
|
507
|
+
/** `check`: what the registry's own CLI said about the credential in the environment. */
|
|
508
|
+
check?: { ok: boolean; command: string };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export interface SecretsReport {
|
|
512
|
+
secrets: SecretRow[];
|
|
513
|
+
/** The one-time human steps with no API; the same list `bootstrap-repo` prints. */
|
|
514
|
+
manual: string[];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Where the end user of the built tool types a config value, per selected surface: the same text
|
|
519
|
+
* the README's Configuration lines and the skill's operations block carry, so an agent inside a
|
|
520
|
+
* harness can read it without calling anything. No runtime host detection — the selection is the
|
|
521
|
+
* only input, because that is what decides which hosts exist at all.
|
|
522
|
+
*/
|
|
523
|
+
const CONFIG_HOSTS: Partial<Record<SurfaceId, (project: Project, name: string) => string>> = {
|
|
524
|
+
claude: () =>
|
|
525
|
+
"Claude Code: `/plugin` -> this plugin -> configure. Masked input, stored in the OS keychain.",
|
|
526
|
+
mcpb: () => "Claude Desktop: the masked field in the `.mcpb` install dialog.",
|
|
527
|
+
gemini: (project, name) =>
|
|
528
|
+
`Gemini CLI: \`gemini extensions config ${projectName.gemini(project.identity.name)} ${name}\`.`,
|
|
529
|
+
"openclaw-native": (project) =>
|
|
530
|
+
`OpenClaw: the Control UI at http://localhost:18789 -> Plugins -> ${project.identity.name}; \`uiHints.sensitive\` masks the field.`,
|
|
531
|
+
"hermes-native": () => "Hermes: the env file `hermes config env-path` prints.",
|
|
532
|
+
cursor: (_project, name) =>
|
|
533
|
+
`Cursor: \`\${env:${name}}\` in the MCP entry, with the value exported in the environment.`,
|
|
534
|
+
codex: (_project, name) =>
|
|
535
|
+
`Codex: the environment only — upstream has no plugin secret path (openai/codex#24401), so export ${name} before running \`codex\`.`,
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/** `<root>/.env`, parsed for presence only. Missing or unreadable is simply "nothing set here". */
|
|
539
|
+
function envValues(root: string): Record<string, string | undefined> {
|
|
540
|
+
const path = join(root, ".env");
|
|
541
|
+
if (!existsSync(path)) return {};
|
|
542
|
+
try {
|
|
543
|
+
return parseEnv(readFileSync(path, "utf8")) as Record<string, string | undefined>;
|
|
544
|
+
} catch {
|
|
545
|
+
return {};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Secret *names* GitHub already holds, from `gh secret list`; undefined when `gh` cannot answer. */
|
|
550
|
+
function githubSecretNames(project: Project): Set<string> | undefined {
|
|
551
|
+
const slug = githubSlug(project.identity.repository);
|
|
552
|
+
if (!slug) return undefined;
|
|
553
|
+
const names = new Set<string>();
|
|
554
|
+
let answered = false;
|
|
555
|
+
for (const scope of [[], ["--env", LIVE_ENVIRONMENT]]) {
|
|
556
|
+
const result = spawnSync("gh", ["secret", "list", "--repo", slug, ...scope], {
|
|
557
|
+
encoding: "utf8",
|
|
558
|
+
timeout: 30_000,
|
|
559
|
+
});
|
|
560
|
+
if (result.status !== 0) continue;
|
|
561
|
+
answered = true;
|
|
562
|
+
for (const line of result.stdout.split("\n")) {
|
|
563
|
+
const name = line.trim().split(/\s+/)[0];
|
|
564
|
+
if (name) names.add(name);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return answered ? names : undefined;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The `wxt submit --dry-run` probes read the zips `toolfactory package` writes; without them, say so. */
|
|
571
|
+
function storeZipsPresent(project: Project): boolean {
|
|
572
|
+
return existsSync(join(project.root, BROWSER_HOST_DIR, ".output"));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function releaseHowTo(project: Project, row: Registry, name: string): string[] {
|
|
576
|
+
const lines = [
|
|
577
|
+
`Mint it at ${row.url}.`,
|
|
578
|
+
`Add \`${name}=\` to .env, then \`${toolfactoryCli(project)} bootstrap-repo\` pushes it as a repository secret.`,
|
|
579
|
+
`Used by the release's \`${releaseJob(row)}\` leg.`,
|
|
580
|
+
];
|
|
581
|
+
if (!row.probe) {
|
|
582
|
+
lines.push(
|
|
583
|
+
`No read-only endpoint exists for ${row.id}, so \`secrets check\` cannot verify this one; the release's own submit is the check.`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
return lines;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function configHowTo(project: Project, name: string): string[] {
|
|
590
|
+
const lines = [
|
|
591
|
+
`Add \`${name}=\` to .env — gitignored, and what the live tests and \`${toolfactoryCli(project)} bootstrap-repo\` read.`,
|
|
592
|
+
];
|
|
593
|
+
if (has(project, "web")) {
|
|
594
|
+
lines.push(
|
|
595
|
+
`Or open the Secrets panel in a browser: \`${project.identity.name} mcp --http --open\`.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
for (const [surface, line] of Object.entries(CONFIG_HOSTS)) {
|
|
599
|
+
if (has(project, surface)) lines.push(line(project, name));
|
|
600
|
+
}
|
|
601
|
+
return lines;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The live test is the check for config keys: it is already gated on exactly these credentials. */
|
|
605
|
+
function liveTestCommand(project: Project): string {
|
|
606
|
+
return project.tool.binding === "python"
|
|
607
|
+
? LIVE_TEST_COMMAND
|
|
608
|
+
: PACKAGE_MANAGER_COMMANDS[project.packageManager ?? "npm"].run("test:live");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** The release.yml job a registry row's leg lives in. */
|
|
612
|
+
function releaseJob(row: Registry): string {
|
|
613
|
+
if (["chrome", "firefox", "edge"].includes(row.id)) return "publish-browser-ext";
|
|
614
|
+
if (row.id === "safari") return "publish-browser-ext-safari";
|
|
615
|
+
if (row.id === "pages") return "pages-deploy";
|
|
616
|
+
return `publish-${row.id === "clawhub-package" ? "clawhub" : row.id}`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function secretsReport(
|
|
620
|
+
project: Project,
|
|
621
|
+
action: "status" | "check" = "status",
|
|
622
|
+
key?: string,
|
|
623
|
+
): SecretsReport {
|
|
624
|
+
const values = { ...envValues(project.root) };
|
|
625
|
+
const properties = configProperties(project);
|
|
626
|
+
const required = new Set(requiredConfig(project));
|
|
627
|
+
const github = githubSecretNames(project);
|
|
628
|
+
const rows: SecretRow[] = [];
|
|
629
|
+
|
|
630
|
+
for (const [configKey, property] of Object.entries(properties)) {
|
|
631
|
+
if (!isSensitive(property)) continue;
|
|
632
|
+
const name = envName(configKey);
|
|
633
|
+
const meta = (property["x-toolfactory"] ?? {}) as { url?: string };
|
|
634
|
+
rows.push({
|
|
635
|
+
name,
|
|
636
|
+
kind: "config",
|
|
637
|
+
required: required.has(configKey),
|
|
638
|
+
needs: Object.keys(CONFIG_HOSTS).filter((surface) => has(project, surface)),
|
|
639
|
+
local: Boolean(values[name] ?? process.env[name]),
|
|
640
|
+
...(github ? { github: github.has(name) } : {}),
|
|
641
|
+
...(meta.url ? { url: meta.url } : {}),
|
|
642
|
+
howTo: configHowTo(project, name),
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const byName = new Map<string, Registry[]>();
|
|
647
|
+
for (const row of registries(project)) {
|
|
648
|
+
for (const name of row.secrets) byName.set(name, [...(byName.get(name) ?? []), row]);
|
|
649
|
+
}
|
|
650
|
+
for (const [name, owners] of byName) {
|
|
651
|
+
const first = owners[0] as Registry;
|
|
652
|
+
rows.push({
|
|
653
|
+
name,
|
|
654
|
+
kind: "release",
|
|
655
|
+
required: false,
|
|
656
|
+
needs: owners.map((row) => row.id),
|
|
657
|
+
local: Boolean(values[name] ?? process.env[name]),
|
|
658
|
+
...(github ? { github: github.has(name) } : {}),
|
|
659
|
+
url: first.url,
|
|
660
|
+
howTo: releaseHowTo(project, first, name),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const selected = key ? rows.filter((row) => row.name === key) : rows;
|
|
665
|
+
if (action === "check") checkSecrets(project, selected, values);
|
|
666
|
+
return { secrets: selected, manual: manualSteps(project) };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Delegation, never re-implemented auth: each release row runs the one upstream command its
|
|
671
|
+
* registry answers with, and config keys are checked by the T4 live test, which is already gated
|
|
672
|
+
* on exactly them and is already the author's own proof that the credential works.
|
|
673
|
+
*/
|
|
674
|
+
function checkSecrets(
|
|
675
|
+
project: Project,
|
|
676
|
+
rows: SecretRow[],
|
|
677
|
+
values: Record<string, string | undefined>,
|
|
678
|
+
): void {
|
|
679
|
+
const present = (name: string) => Boolean(values[name] ?? process.env[name]);
|
|
680
|
+
const table = registries(project);
|
|
681
|
+
for (const row of rows) {
|
|
682
|
+
if (row.kind !== "release" || !row.local) continue;
|
|
683
|
+
const registry = table.find((entry) => entry.id === row.needs[0]);
|
|
684
|
+
if (!registry?.probe || !registry.secrets.every(present)) continue;
|
|
685
|
+
if (registry.surfaces.includes("browser-extension") && !storeZipsPresent(project)) {
|
|
686
|
+
row.howTo.push(
|
|
687
|
+
`\`${toolfactoryCli(project)} package\` first: the store dry run submits the zips it writes.`,
|
|
688
|
+
);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const outcome = run({
|
|
692
|
+
label: registry.id,
|
|
693
|
+
command: "bash",
|
|
694
|
+
args: ["-c", registry.probe],
|
|
695
|
+
cwd: project.root,
|
|
696
|
+
env: values as Record<string, string>,
|
|
697
|
+
});
|
|
698
|
+
row.check = { ok: outcome.ok, command: registry.probe };
|
|
699
|
+
}
|
|
700
|
+
const live = liveCredentials(project);
|
|
701
|
+
if (!live.length || !live.map(envName).every(present)) return;
|
|
702
|
+
const command = liveTestCommand(project);
|
|
703
|
+
const outcome = run({
|
|
704
|
+
label: "live tests",
|
|
705
|
+
command: "bash",
|
|
706
|
+
args: ["-c", command],
|
|
707
|
+
cwd: project.root,
|
|
708
|
+
env: values as Record<string, string>,
|
|
709
|
+
});
|
|
710
|
+
for (const row of rows) {
|
|
711
|
+
if (row.kind === "config") row.check = { ok: outcome.ok, command };
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** The whole inventory: what each credential is for, where it is set, and (check) whether it works. */
|
|
716
|
+
export function secrets(args: {
|
|
717
|
+
root?: string;
|
|
718
|
+
action?: "status" | "check";
|
|
719
|
+
key?: string;
|
|
720
|
+
}): SecretsReport {
|
|
721
|
+
return secretsReport(loadProject(args.root ?? "."), args.action ?? "status", args.key);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** One line for `init`'s next steps; nothing at all when the project declares no credential. */
|
|
725
|
+
function secretsSummary(report: SecretsReport): string | undefined {
|
|
726
|
+
const { secrets: rows } = report;
|
|
727
|
+
if (!rows.length) return undefined;
|
|
728
|
+
const missing = rows.filter((row) => !row.local).map((row) => row.name);
|
|
729
|
+
return `Secrets: ${rows.length - missing.length} of ${rows.length} present in .env${
|
|
730
|
+
missing.length ? ` (missing ${missing.join(", ")})` : ""
|
|
731
|
+
}. \`toolfactory secrets\` prints where each one is set, per host and per registry.`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ---------------------------------------------------------------------------------------------
|
|
735
|
+
// unpublish — deselect's other half. Git is the ledger: the previous tag's tool.json says what
|
|
736
|
+
// this project used to publish, and every row that lost its surface gets retracted.
|
|
737
|
+
// ---------------------------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
export interface UnpublishResult {
|
|
740
|
+
/** The tag the previous selection came from; absent when there is no earlier tag to diff against. */
|
|
741
|
+
from?: string;
|
|
742
|
+
/** Surfaces selected at that tag, no longer selected, and published somewhere. */
|
|
743
|
+
dropped: SurfaceId[];
|
|
744
|
+
steps: { name: string; run: string }[];
|
|
745
|
+
/**
|
|
746
|
+
* What each step did (omitted by `dryRun`). Best-effort on purpose: a registry that refuses a
|
|
747
|
+
* retraction must never stop the release it is riding along with from being cut.
|
|
748
|
+
*/
|
|
749
|
+
ran?: StepResult[];
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** The project as the previous tag left it, plus the surfaces it published that are now gone. */
|
|
753
|
+
function retractable(
|
|
754
|
+
project: Project,
|
|
755
|
+
tag: string,
|
|
756
|
+
tool: ToolConfig,
|
|
757
|
+
): { previous: Project; dropped: SurfaceId[] } {
|
|
758
|
+
const previous: Project = {
|
|
759
|
+
...project,
|
|
760
|
+
tool,
|
|
761
|
+
// The gate asserts `v<version>` against the tag, so the tag *is* the version that tag published.
|
|
762
|
+
identity: { ...project.identity, version: tag.replace(/^v/, "") },
|
|
763
|
+
};
|
|
764
|
+
const published = new Set(registries(previous).flatMap((row) => row.surfaces));
|
|
765
|
+
return {
|
|
766
|
+
previous,
|
|
767
|
+
dropped: droppedSurfaces(tool.surfaces, project.tool.surfaces).filter((surface) =>
|
|
768
|
+
published.has(surface),
|
|
769
|
+
),
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function unpublish(
|
|
774
|
+
root = ".",
|
|
775
|
+
options: { ref?: string; dryRun?: boolean; hard?: boolean } = {},
|
|
776
|
+
): UnpublishResult {
|
|
777
|
+
const project = loadProject(root);
|
|
778
|
+
const from = previousTag(project.root, options.ref ?? "HEAD");
|
|
779
|
+
const tool = from ? toolAtRef(project.root, from) : undefined;
|
|
780
|
+
if (!from || !tool) return { from, dropped: [], steps: [] };
|
|
781
|
+
const { previous, dropped } = retractable(project, from, tool);
|
|
782
|
+
const steps = unpublishSteps(previous, dropped, options.hard);
|
|
783
|
+
const listed = steps.map(({ name, run }) => ({ name, run }));
|
|
784
|
+
if (options.dryRun) return { from, dropped, steps: listed };
|
|
785
|
+
return { from, dropped, steps: listed, ran: runSteps(project.root, steps).steps };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** `build`'s one line: a surface that published somewhere is gone, and nothing has retracted it yet. */
|
|
789
|
+
function unpublishNudge(project: Project): string | undefined {
|
|
790
|
+
const from = previousTag(project.root);
|
|
791
|
+
const tool = from ? toolAtRef(project.root, from) : undefined;
|
|
792
|
+
if (!from || !tool) return undefined;
|
|
793
|
+
const { dropped } = retractable(project, from, tool);
|
|
794
|
+
if (!dropped.length) return undefined;
|
|
795
|
+
return `${dropped.join(", ")} published at ${from} and is no longer selected: \`${toolfactoryCli(project)} unpublish --dry-run\` shows what the next release will retract.`;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export function adopt(root: string, path: string): void {
|
|
799
|
+
setState(resolve(root), path, "manual", TOOLFACTORY_VERSION);
|
|
800
|
+
build(root);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export function unadopt(root: string, path: string): void {
|
|
804
|
+
setState(resolve(root), path, "generated", TOOLFACTORY_VERSION);
|
|
805
|
+
build(root);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export function eject(root: string, surface: SurfaceId): string[] {
|
|
809
|
+
const project = loadProject(root);
|
|
810
|
+
const owned = buildPlan(project, selectedSurfaces([surface])).map((file) => file.path);
|
|
811
|
+
const lock = readLock(project.root);
|
|
812
|
+
const adopted: string[] = [];
|
|
813
|
+
for (const path of owned) {
|
|
814
|
+
if (lock?.files[path]) {
|
|
815
|
+
setState(project.root, path, "manual", TOOLFACTORY_VERSION);
|
|
816
|
+
adopted.push(path);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
build(root);
|
|
820
|
+
return adopted;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export interface DoctorReport {
|
|
824
|
+
toolfactory: string;
|
|
825
|
+
node: string;
|
|
826
|
+
tools: Record<string, string>;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Report the upstream CLIs this machine can delegate to. */
|
|
830
|
+
/**
|
|
831
|
+
* Prepare the GitHub repository: the `live-tests` environment and its secrets, the release
|
|
832
|
+
* registries' repository secrets, Pages, and npm's trusted publisher — everything GitHub only
|
|
833
|
+
* exposes over its API, from the one table and the one `.env`.
|
|
834
|
+
*/
|
|
835
|
+
export function bootstrapRepo(args: {
|
|
836
|
+
root: string;
|
|
837
|
+
reviewers?: string[];
|
|
838
|
+
dryRun?: boolean;
|
|
839
|
+
}): BootstrapResult {
|
|
840
|
+
const project = loadProject(args.root);
|
|
841
|
+
return prepareRepository(project, {
|
|
842
|
+
reviewers: args.reviewers,
|
|
843
|
+
dryRun: args.dryRun,
|
|
844
|
+
releaseSecrets: registries(project).flatMap((row) => row.secrets),
|
|
845
|
+
manual: manualSteps(project),
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export function doctor(): DoctorReport {
|
|
850
|
+
const probes: Record<string, [string, string[]]> = {
|
|
851
|
+
git: ["git", ["--version"]],
|
|
852
|
+
gh: ["gh", ["--version"]],
|
|
853
|
+
npm: ["npm", ["--version"]],
|
|
854
|
+
uv: ["uv", ["--version"]],
|
|
855
|
+
claude: ["claude", ["--version"]],
|
|
856
|
+
openclaw: ["openclaw", ["--version"]],
|
|
857
|
+
clawhub: ["clawhub", ["--version"]],
|
|
858
|
+
hermes: ["hermes", ["--version"]],
|
|
859
|
+
uvx: ["uvx", ["--version"]],
|
|
860
|
+
agentskills: ["uvx", ["--from", "skills-ref", "agentskills", "--help"]],
|
|
861
|
+
docker: ["docker", ["--version"]],
|
|
862
|
+
};
|
|
863
|
+
const tools: Record<string, string> = {};
|
|
864
|
+
// Never spawn npx from here: under an npx-launched MCP host that deadlocks on npm's cache lock.
|
|
865
|
+
try {
|
|
866
|
+
const inspector = createRequire(import.meta.url)(
|
|
867
|
+
"@modelcontextprotocol/inspector/package.json",
|
|
868
|
+
) as {
|
|
869
|
+
version: string;
|
|
870
|
+
};
|
|
871
|
+
tools["mcp-inspector"] = `${inspector.version} (npx @modelcontextprotocol/inspector)`;
|
|
872
|
+
} catch {
|
|
873
|
+
tools["mcp-inspector"] =
|
|
874
|
+
"missing (npx --yes @modelcontextprotocol/inspector installs it on demand)";
|
|
875
|
+
}
|
|
876
|
+
for (const [name, [command, args]] of Object.entries(probes)) {
|
|
877
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: 10_000 });
|
|
878
|
+
tools[name] =
|
|
879
|
+
result.status === 0
|
|
880
|
+
? ((result.stdout || result.stderr).trim().split("\n")[0] ?? "ok")
|
|
881
|
+
: "missing";
|
|
882
|
+
}
|
|
883
|
+
return { toolfactory: TOOLFACTORY_VERSION, node: process.version, tools };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export const SURFACES = SURFACE_IDS;
|