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
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web UI: `web/` is a Vite + React + Tailwind v4 + shadcn/ui project, in exactly the shape
|
|
3
|
+
* `npm create vite`, the Tailwind and alias steps from ui.shadcn.com/docs/installation/vite,
|
|
4
|
+
* and `shadcn init` produce. `App.tsx` renders one shadcn form per operation from the
|
|
5
|
+
* `web/src/ops.json` snapshot, previews the equivalent CLI invocation and MCP `tools/call`
|
|
6
|
+
* request, and POSTs that request to a streamable-HTTP MCP server with the 2026-07-28
|
|
7
|
+
* per-request `_meta` fields (there is no handshake in that revision: protocol version,
|
|
8
|
+
* capabilities and client identity travel on every request instead). The dev server proxies
|
|
9
|
+
* `/mcp` to the kernel, so the page never needs CORS.
|
|
10
|
+
*
|
|
11
|
+
* §8 C2, "mirror scaffolds by execution, not transcription": `plan()` is pure and therefore
|
|
12
|
+
* has to carry the scaffolders' output, so every pinned value lives in WEB_SCAFFOLD below and
|
|
13
|
+
* nowhere else, and `validate()` runs `src/hosts/web.ts`, which re-runs the real generators in
|
|
14
|
+
* a temp directory and fails naming whatever drifted. The components under
|
|
15
|
+
* `web/src/components/ui/` are deliberately not planned: they are the shadcn CLI's to write
|
|
16
|
+
* into the author's tree, and `validate()` asks it for them.
|
|
17
|
+
*/
|
|
18
|
+
import { execArgv } from "node:process";
|
|
19
|
+
import { getBinding } from "../bindings/index.js";
|
|
20
|
+
import { DRIFT_ENTRY } from "../hosts/web.js";
|
|
21
|
+
import { includedOperations } from "../report/coverage.js";
|
|
22
|
+
import { envName, has, json, mcpVerdict } from "./shared.js";
|
|
23
|
+
export const WEB_DIR = "web";
|
|
24
|
+
/** MCP revision whose per-request `_meta` keys the page sends. */
|
|
25
|
+
const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
26
|
+
/**
|
|
27
|
+
* Where the page POSTs by default, relative to the document: one build then serves under `/`
|
|
28
|
+
* (the kernel's own `mcp --http`), under a plugin's prefix (the OpenClaw Control UI tab), and
|
|
29
|
+
* on GitHub Pages, without a rebuild. The dev server proxies the same path to the kernel.
|
|
30
|
+
*/
|
|
31
|
+
const ENDPOINT_PATH = "mcp";
|
|
32
|
+
/**
|
|
33
|
+
* Everything the upstream generators wrote, transcribed once; `validate()` proves it current.
|
|
34
|
+
* Sources, in the order `src/hosts/web.ts` replays them:
|
|
35
|
+
* npm create vite@latest <dir> -- --template react-ts
|
|
36
|
+
* ui.shadcn.com/docs/installation/vite (Tailwind, the `@/*` alias, vite.config.ts)
|
|
37
|
+
* npx shadcn@latest init -y --base base --preset nova
|
|
38
|
+
* npx shadcn@latest add -y <components>
|
|
39
|
+
*/
|
|
40
|
+
export const WEB_SCAFFOLD = {
|
|
41
|
+
viteTemplate: "react-ts",
|
|
42
|
+
base: "base",
|
|
43
|
+
preset: "nova",
|
|
44
|
+
/** The shadcn components the page composes; the CLI copies them into src/components/ui. */
|
|
45
|
+
components: [
|
|
46
|
+
"alert",
|
|
47
|
+
"badge",
|
|
48
|
+
"button",
|
|
49
|
+
"card",
|
|
50
|
+
"empty",
|
|
51
|
+
"field",
|
|
52
|
+
"input",
|
|
53
|
+
"select",
|
|
54
|
+
"switch",
|
|
55
|
+
"tabs",
|
|
56
|
+
"textarea",
|
|
57
|
+
"toggle-group",
|
|
58
|
+
],
|
|
59
|
+
/** toolfactory's own addition: the browser the smoke script drives. */
|
|
60
|
+
playwright: "^1.62.1",
|
|
61
|
+
/** `npm create vite` output, before the shadcn documentation's edits. */
|
|
62
|
+
vite: {
|
|
63
|
+
"vite.config.ts": `import react from '@vitejs/plugin-react'
|
|
64
|
+
import { defineConfig } from 'vite'
|
|
65
|
+
|
|
66
|
+
// https://vite.dev/config/
|
|
67
|
+
export default defineConfig({
|
|
68
|
+
plugins: [react()],
|
|
69
|
+
})
|
|
70
|
+
`,
|
|
71
|
+
"tsconfig.json": `{
|
|
72
|
+
"files": [],
|
|
73
|
+
"references": [
|
|
74
|
+
{ "path": "./tsconfig.app.json" },
|
|
75
|
+
{ "path": "./tsconfig.node.json" }
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
`,
|
|
79
|
+
"tsconfig.app.json": `{
|
|
80
|
+
"compilerOptions": {
|
|
81
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
82
|
+
"target": "es2023",
|
|
83
|
+
"lib": ["ES2023", "DOM"],
|
|
84
|
+
"module": "esnext",
|
|
85
|
+
"types": ["vite/client"],
|
|
86
|
+
"allowArbitraryExtensions": true,
|
|
87
|
+
"skipLibCheck": true,
|
|
88
|
+
|
|
89
|
+
/* Bundler mode */
|
|
90
|
+
"moduleResolution": "bundler",
|
|
91
|
+
"allowImportingTsExtensions": true,
|
|
92
|
+
"verbatimModuleSyntax": true,
|
|
93
|
+
"moduleDetection": "force",
|
|
94
|
+
"noEmit": true,
|
|
95
|
+
"jsx": "react-jsx",
|
|
96
|
+
|
|
97
|
+
/* Linting */
|
|
98
|
+
"noUnusedLocals": true,
|
|
99
|
+
"noUnusedParameters": true,
|
|
100
|
+
"erasableSyntaxOnly": true,
|
|
101
|
+
"noFallthroughCasesInSwitch": true
|
|
102
|
+
},
|
|
103
|
+
"include": ["src"]
|
|
104
|
+
}
|
|
105
|
+
`,
|
|
106
|
+
"tsconfig.node.json": `{
|
|
107
|
+
"compilerOptions": {
|
|
108
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
109
|
+
"target": "es2023",
|
|
110
|
+
"lib": ["ES2023"],
|
|
111
|
+
"types": ["node"],
|
|
112
|
+
"skipLibCheck": true,
|
|
113
|
+
|
|
114
|
+
/* Bundler mode */
|
|
115
|
+
"module": "nodenext",
|
|
116
|
+
"allowImportingTsExtensions": true,
|
|
117
|
+
"verbatimModuleSyntax": true,
|
|
118
|
+
"moduleDetection": "force",
|
|
119
|
+
"noEmit": true,
|
|
120
|
+
|
|
121
|
+
/* Linting */
|
|
122
|
+
"noUnusedLocals": true,
|
|
123
|
+
"noUnusedParameters": true,
|
|
124
|
+
"erasableSyntaxOnly": true,
|
|
125
|
+
"noFallthroughCasesInSwitch": true
|
|
126
|
+
},
|
|
127
|
+
"include": ["vite.config.ts"]
|
|
128
|
+
}
|
|
129
|
+
`,
|
|
130
|
+
".oxlintrc.json": `{
|
|
131
|
+
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
132
|
+
"plugins": ["react", "typescript", "oxc"],
|
|
133
|
+
"rules": {
|
|
134
|
+
"react/rules-of-hooks": "error",
|
|
135
|
+
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`,
|
|
139
|
+
},
|
|
140
|
+
packageJson: {
|
|
141
|
+
scripts: {
|
|
142
|
+
dev: "vite",
|
|
143
|
+
build: "tsc -b && vite build",
|
|
144
|
+
lint: "oxlint",
|
|
145
|
+
preview: "vite preview",
|
|
146
|
+
},
|
|
147
|
+
dependencies: {
|
|
148
|
+
"@base-ui/react": "^1.8.0",
|
|
149
|
+
"@fontsource-variable/geist": "^5.3.0",
|
|
150
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
151
|
+
"class-variance-authority": "^0.7.1",
|
|
152
|
+
cn: "^0.2.6",
|
|
153
|
+
"lucide-react": "^1.42.0",
|
|
154
|
+
react: "^19.2.8",
|
|
155
|
+
"react-dom": "^19.2.8",
|
|
156
|
+
shadcn: "^4.21.0",
|
|
157
|
+
tailwindcss: "^4.3.3",
|
|
158
|
+
"tw-animate-css": "^1.4.0",
|
|
159
|
+
},
|
|
160
|
+
devDependencies: {
|
|
161
|
+
"@types/node": "^24.13.3",
|
|
162
|
+
"@types/react": "^19.2.18",
|
|
163
|
+
"@types/react-dom": "^19.2.4",
|
|
164
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
165
|
+
oxlint: "^1.79.0",
|
|
166
|
+
typescript: "~6.0.2",
|
|
167
|
+
vite: "^8.2.2",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
componentsJson: {
|
|
171
|
+
$schema: "https://ui.shadcn.com/schema.json",
|
|
172
|
+
style: "base-nova",
|
|
173
|
+
rsc: false,
|
|
174
|
+
tsx: true,
|
|
175
|
+
tailwind: {
|
|
176
|
+
config: "",
|
|
177
|
+
css: "src/index.css",
|
|
178
|
+
baseColor: "neutral",
|
|
179
|
+
cssVariables: true,
|
|
180
|
+
prefix: "",
|
|
181
|
+
},
|
|
182
|
+
iconLibrary: "lucide",
|
|
183
|
+
rtl: false,
|
|
184
|
+
aliases: {
|
|
185
|
+
components: "@/components",
|
|
186
|
+
utils: "@/lib/utils",
|
|
187
|
+
ui: "@/components/ui",
|
|
188
|
+
lib: "@/lib",
|
|
189
|
+
hooks: "@/hooks",
|
|
190
|
+
},
|
|
191
|
+
menuColor: "default",
|
|
192
|
+
menuAccent: "subtle",
|
|
193
|
+
registries: {},
|
|
194
|
+
},
|
|
195
|
+
indexCss: `@import "tailwindcss";
|
|
196
|
+
@import "tw-animate-css";
|
|
197
|
+
@import "shadcn/tailwind.css";
|
|
198
|
+
@import "@fontsource-variable/geist";
|
|
199
|
+
|
|
200
|
+
@custom-variant dark (&:is(.dark *));
|
|
201
|
+
|
|
202
|
+
@theme inline {
|
|
203
|
+
--font-heading: var(--font-sans);
|
|
204
|
+
--font-sans: 'Geist Variable', sans-serif;
|
|
205
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
206
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
207
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
208
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
209
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
210
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
211
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
212
|
+
--color-sidebar: var(--sidebar);
|
|
213
|
+
--color-chart-5: var(--chart-5);
|
|
214
|
+
--color-chart-4: var(--chart-4);
|
|
215
|
+
--color-chart-3: var(--chart-3);
|
|
216
|
+
--color-chart-2: var(--chart-2);
|
|
217
|
+
--color-chart-1: var(--chart-1);
|
|
218
|
+
--color-ring: var(--ring);
|
|
219
|
+
--color-input: var(--input);
|
|
220
|
+
--color-border: var(--border);
|
|
221
|
+
--color-destructive: var(--destructive);
|
|
222
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
223
|
+
--color-accent: var(--accent);
|
|
224
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
225
|
+
--color-muted: var(--muted);
|
|
226
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
227
|
+
--color-secondary: var(--secondary);
|
|
228
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
229
|
+
--color-primary: var(--primary);
|
|
230
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
231
|
+
--color-popover: var(--popover);
|
|
232
|
+
--color-card-foreground: var(--card-foreground);
|
|
233
|
+
--color-card: var(--card);
|
|
234
|
+
--color-foreground: var(--foreground);
|
|
235
|
+
--color-background: var(--background);
|
|
236
|
+
--radius-sm: calc(var(--radius) * 0.6);
|
|
237
|
+
--radius-md: calc(var(--radius) * 0.8);
|
|
238
|
+
--radius-lg: var(--radius);
|
|
239
|
+
--radius-xl: calc(var(--radius) * 1.4);
|
|
240
|
+
--radius-2xl: calc(var(--radius) * 1.8);
|
|
241
|
+
--radius-3xl: calc(var(--radius) * 2.2);
|
|
242
|
+
--radius-4xl: calc(var(--radius) * 2.6);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
:root {
|
|
246
|
+
--background: oklch(1 0 0);
|
|
247
|
+
--foreground: oklch(0.145 0 0);
|
|
248
|
+
--card: oklch(1 0 0);
|
|
249
|
+
--card-foreground: oklch(0.145 0 0);
|
|
250
|
+
--popover: oklch(1 0 0);
|
|
251
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
252
|
+
--primary: oklch(0.205 0 0);
|
|
253
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
254
|
+
--secondary: oklch(0.97 0 0);
|
|
255
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
256
|
+
--muted: oklch(0.97 0 0);
|
|
257
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
258
|
+
--accent: oklch(0.97 0 0);
|
|
259
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
260
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
261
|
+
--border: oklch(0.922 0 0);
|
|
262
|
+
--input: oklch(0.922 0 0);
|
|
263
|
+
--ring: oklch(0.708 0 0);
|
|
264
|
+
--chart-1: oklch(0.87 0 0);
|
|
265
|
+
--chart-2: oklch(0.556 0 0);
|
|
266
|
+
--chart-3: oklch(0.439 0 0);
|
|
267
|
+
--chart-4: oklch(0.371 0 0);
|
|
268
|
+
--chart-5: oklch(0.269 0 0);
|
|
269
|
+
--radius: 0.625rem;
|
|
270
|
+
--sidebar: oklch(0.985 0 0);
|
|
271
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
272
|
+
--sidebar-primary: oklch(0.205 0 0);
|
|
273
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
274
|
+
--sidebar-accent: oklch(0.97 0 0);
|
|
275
|
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
276
|
+
--sidebar-border: oklch(0.922 0 0);
|
|
277
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.dark {
|
|
281
|
+
--background: oklch(0.145 0 0);
|
|
282
|
+
--foreground: oklch(0.985 0 0);
|
|
283
|
+
--card: oklch(0.205 0 0);
|
|
284
|
+
--card-foreground: oklch(0.985 0 0);
|
|
285
|
+
--popover: oklch(0.205 0 0);
|
|
286
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
287
|
+
--primary: oklch(0.922 0 0);
|
|
288
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
289
|
+
--secondary: oklch(0.269 0 0);
|
|
290
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
291
|
+
--muted: oklch(0.269 0 0);
|
|
292
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
293
|
+
--accent: oklch(0.269 0 0);
|
|
294
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
295
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
296
|
+
--border: oklch(1 0 0 / 10%);
|
|
297
|
+
--input: oklch(1 0 0 / 15%);
|
|
298
|
+
--ring: oklch(0.556 0 0);
|
|
299
|
+
--chart-1: oklch(0.87 0 0);
|
|
300
|
+
--chart-2: oklch(0.556 0 0);
|
|
301
|
+
--chart-3: oklch(0.439 0 0);
|
|
302
|
+
--chart-4: oklch(0.371 0 0);
|
|
303
|
+
--chart-5: oklch(0.269 0 0);
|
|
304
|
+
--sidebar: oklch(0.205 0 0);
|
|
305
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
306
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
307
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
308
|
+
--sidebar-accent: oklch(0.269 0 0);
|
|
309
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
310
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
311
|
+
--sidebar-ring: oklch(0.556 0 0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@layer base {
|
|
315
|
+
* {
|
|
316
|
+
@apply border-border outline-ring/50;
|
|
317
|
+
}
|
|
318
|
+
body {
|
|
319
|
+
@apply bg-background text-foreground;
|
|
320
|
+
}
|
|
321
|
+
html {
|
|
322
|
+
@apply font-sans;
|
|
323
|
+
}
|
|
324
|
+
}`,
|
|
325
|
+
utils: `export { cn } from "cn"
|
|
326
|
+
`,
|
|
327
|
+
};
|
|
328
|
+
/** `src/index.css` after the documentation's Tailwind step and before `shadcn init` rewrites it. */
|
|
329
|
+
export const TAILWIND_CSS = '@import "tailwindcss";\n';
|
|
330
|
+
const MAIN_TSX = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
331
|
+
import { StrictMode } from "react"
|
|
332
|
+
import { createRoot } from "react-dom/client"
|
|
333
|
+
import "./index.css"
|
|
334
|
+
import App from "./App.tsx"
|
|
335
|
+
|
|
336
|
+
document.documentElement.classList.toggle(
|
|
337
|
+
"dark",
|
|
338
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
createRoot(document.getElementById("root")!).render(
|
|
342
|
+
<StrictMode>
|
|
343
|
+
<App />
|
|
344
|
+
</StrictMode>,
|
|
345
|
+
)
|
|
346
|
+
`;
|
|
347
|
+
/**
|
|
348
|
+
* `src/App.tsx` is a RegionFile: the generated region is the operations page (everything
|
|
349
|
+
* the operator forms need, over `ops.json`) exported as `OperationsPage`; the template's tail —
|
|
350
|
+
* never touched again once the file exists — is the author's `pages` array and the shell that
|
|
351
|
+
* renders whichever one is active. A page an author adds below the end marker (the d3
|
|
352
|
+
* degree-planner graph vutoolkit needs, say) survives every `toolfactory build` because
|
|
353
|
+
* `apply()` only ever replaces the text between the markers (src/project/apply.ts `render()`).
|
|
354
|
+
*/
|
|
355
|
+
export const APP_BEGIN = "// tf:app";
|
|
356
|
+
export const APP_END = "// /tf:app";
|
|
357
|
+
const APP_REGION = `
|
|
358
|
+
// Generated by toolfactory. Do not edit this region; run \`toolfactory build\` to regenerate.
|
|
359
|
+
// Everything below the end marker is yours — add pages there and they survive a rebuild.
|
|
360
|
+
import { useEffect, useState, type ReactNode } from "react"
|
|
361
|
+
import { MoonIcon, SunIcon } from "lucide-react"
|
|
362
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
363
|
+
import { Badge } from "@/components/ui/badge"
|
|
364
|
+
import { Button } from "@/components/ui/button"
|
|
365
|
+
import {
|
|
366
|
+
Card,
|
|
367
|
+
CardContent,
|
|
368
|
+
CardDescription,
|
|
369
|
+
CardHeader,
|
|
370
|
+
CardTitle,
|
|
371
|
+
} from "@/components/ui/card"
|
|
372
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
|
373
|
+
import { Field, FieldLabel } from "@/components/ui/field"
|
|
374
|
+
import { Input } from "@/components/ui/input"
|
|
375
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
376
|
+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
|
377
|
+
import {
|
|
378
|
+
initialValues,
|
|
379
|
+
SchemaFields,
|
|
380
|
+
toArguments,
|
|
381
|
+
type FieldValue,
|
|
382
|
+
type FormValues,
|
|
383
|
+
type JsonSchema,
|
|
384
|
+
} from "@/schema-form"
|
|
385
|
+
import page from "./ops.json"
|
|
386
|
+
|
|
387
|
+
interface Operation {
|
|
388
|
+
name: string
|
|
389
|
+
description?: string
|
|
390
|
+
inputSchema: JsonSchema
|
|
391
|
+
}
|
|
392
|
+
interface Page {
|
|
393
|
+
tool: { name: string; version?: string; description?: string }
|
|
394
|
+
cliAvailable: boolean
|
|
395
|
+
defaultEndpoint: string
|
|
396
|
+
mcpProtocolVersion: string
|
|
397
|
+
operations: Operation[]
|
|
398
|
+
excluded: { name: string; reason?: string }[]
|
|
399
|
+
}
|
|
400
|
+
/** TypeScript widens ops.json into a union of literal schemas; the page reads it as one shape. */
|
|
401
|
+
const DATA = page as unknown as Page
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Same origin as whatever served this document, so one build works under \`/\` (the kernel's
|
|
405
|
+
* \`mcp --http\`), under a plugin route's prefix, and on GitHub Pages.
|
|
406
|
+
*/
|
|
407
|
+
const DEFAULT_ENDPOINT = new URL(DATA.defaultEndpoint, document.baseURI).href
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* \`<url>/#<token>\` is what \`mcp --http --open\` opens: read the fragment once, keep it in
|
|
411
|
+
* memory and strip it from the address bar, then present it on the API calls. Nothing stores it.
|
|
412
|
+
*/
|
|
413
|
+
const TOKEN = (() => {
|
|
414
|
+
const fragment = window.location.hash.replace(/^#/, "")
|
|
415
|
+
if (!fragment) return ""
|
|
416
|
+
window.history.replaceState(null, "", window.location.pathname + window.location.search)
|
|
417
|
+
return fragment
|
|
418
|
+
})()
|
|
419
|
+
|
|
420
|
+
/** \`Authorization\` when this page was opened with a token; nothing when it was not. */
|
|
421
|
+
function authorization(): Record<string, string> {
|
|
422
|
+
return TOKEN ? { Authorization: \`Bearer \${TOKEN}\` } : {}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
interface Outcome {
|
|
426
|
+
cli: string | null
|
|
427
|
+
request: unknown
|
|
428
|
+
result?: unknown
|
|
429
|
+
error?: string
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function mcpRequest(operation: Operation, args: Record<string, unknown>) {
|
|
433
|
+
return {
|
|
434
|
+
jsonrpc: "2.0",
|
|
435
|
+
id: Date.now(),
|
|
436
|
+
method: "tools/call",
|
|
437
|
+
params: {
|
|
438
|
+
name: operation.name,
|
|
439
|
+
arguments: args,
|
|
440
|
+
_meta: {
|
|
441
|
+
"io.modelcontextprotocol/protocolVersion": DATA.mcpProtocolVersion,
|
|
442
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
443
|
+
"io.modelcontextprotocol/clientInfo": {
|
|
444
|
+
name: \`\${DATA.tool.name}-web\`,
|
|
445
|
+
version: DATA.tool.version ?? "0.0.0",
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 2026-07-28 streamable HTTP: no handshake, so protocol version, method and client identity
|
|
454
|
+
* ride on the request itself, and a single tools/call answers with plain JSON or an SSE frame.
|
|
455
|
+
*/
|
|
456
|
+
async function call(
|
|
457
|
+
endpoint: string,
|
|
458
|
+
request: { method: string; params: { name: string } },
|
|
459
|
+
): Promise<unknown> {
|
|
460
|
+
const response = await fetch(endpoint, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: {
|
|
463
|
+
"Content-Type": "application/json",
|
|
464
|
+
Accept: "application/json, text/event-stream",
|
|
465
|
+
"MCP-Protocol-Version": DATA.mcpProtocolVersion,
|
|
466
|
+
"Mcp-Method": request.method,
|
|
467
|
+
"Mcp-Name": request.params.name,
|
|
468
|
+
...authorization(),
|
|
469
|
+
},
|
|
470
|
+
body: JSON.stringify(request),
|
|
471
|
+
})
|
|
472
|
+
const text = await response.text()
|
|
473
|
+
const framed = text.split("\\n").find((line) => line.startsWith("data:"))
|
|
474
|
+
let body: { error?: unknown; result?: { structuredContent?: unknown } }
|
|
475
|
+
try {
|
|
476
|
+
body = JSON.parse(framed ? framed.slice(5).trim() : text)
|
|
477
|
+
} catch {
|
|
478
|
+
throw new Error(\`HTTP \${response.status}: \${text.slice(0, 500)}\`)
|
|
479
|
+
}
|
|
480
|
+
if (body.error) throw new Error(JSON.stringify(body.error))
|
|
481
|
+
return body.result?.structuredContent ?? body.result
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function Preview({ outcome }: { outcome: Outcome }) {
|
|
485
|
+
const tabs = [
|
|
486
|
+
outcome.cli === null ? null : { value: "cli", label: "CLI", body: outcome.cli, failed: false },
|
|
487
|
+
{
|
|
488
|
+
value: "mcp",
|
|
489
|
+
label: "MCP tools/call",
|
|
490
|
+
body: JSON.stringify(outcome.request, null, 2),
|
|
491
|
+
failed: false,
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
value: "result",
|
|
495
|
+
label: outcome.error ? "Error" : "Result",
|
|
496
|
+
body: outcome.error ?? JSON.stringify(outcome.result, null, 2),
|
|
497
|
+
failed: Boolean(outcome.error),
|
|
498
|
+
},
|
|
499
|
+
].filter((tab) => tab !== null)
|
|
500
|
+
return (
|
|
501
|
+
<Tabs defaultValue={tabs[0].value} className="w-full">
|
|
502
|
+
<TabsList variant="line">
|
|
503
|
+
{tabs.map((tab) => (
|
|
504
|
+
<TabsTrigger key={tab.value} value={tab.value}>
|
|
505
|
+
{tab.label}
|
|
506
|
+
</TabsTrigger>
|
|
507
|
+
))}
|
|
508
|
+
</TabsList>
|
|
509
|
+
{tabs.map((tab) => (
|
|
510
|
+
<TabsContent key={tab.value} value={tab.value}>
|
|
511
|
+
<pre
|
|
512
|
+
data-slot={tab.value}
|
|
513
|
+
data-failed={tab.failed}
|
|
514
|
+
className="overflow-x-auto rounded-lg border bg-muted p-3 font-mono text-xs whitespace-pre-wrap data-[failed=true]:border-destructive data-[failed=true]:text-destructive"
|
|
515
|
+
>
|
|
516
|
+
{tab.body}
|
|
517
|
+
</pre>
|
|
518
|
+
</TabsContent>
|
|
519
|
+
))}
|
|
520
|
+
</Tabs>
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function OperationCard({ operation, endpoint }: { operation: Operation; endpoint: string }) {
|
|
525
|
+
const [values, setValues] = useState<FormValues>(() => initialValues(operation.inputSchema))
|
|
526
|
+
const [outcome, setOutcome] = useState<Outcome | null>(null)
|
|
527
|
+
const [busy, setBusy] = useState(false)
|
|
528
|
+
const change = (name: string, value: FieldValue) =>
|
|
529
|
+
setValues((current) => ({ ...current, [name]: value }))
|
|
530
|
+
|
|
531
|
+
async function submit(event: React.FormEvent) {
|
|
532
|
+
event.preventDefault()
|
|
533
|
+
setBusy(true)
|
|
534
|
+
try {
|
|
535
|
+
const args = toArguments(operation.inputSchema, values)
|
|
536
|
+
const request = mcpRequest(operation, args)
|
|
537
|
+
const cli = DATA.cliAvailable
|
|
538
|
+
? \`\${DATA.tool.name} \${operation.name} --json '\${JSON.stringify(args)}'\`
|
|
539
|
+
: null
|
|
540
|
+
const target = endpoint.trim()
|
|
541
|
+
if (!target) {
|
|
542
|
+
setOutcome({ cli, request })
|
|
543
|
+
} else {
|
|
544
|
+
try {
|
|
545
|
+
setOutcome({ cli, request, result: await call(target, request) })
|
|
546
|
+
} catch (error) {
|
|
547
|
+
setOutcome({ cli, request, error: error instanceof Error ? error.message : String(error) })
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
setOutcome({
|
|
552
|
+
cli: null,
|
|
553
|
+
request: null,
|
|
554
|
+
error: error instanceof Error ? error.message : String(error),
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
setBusy(false)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return (
|
|
561
|
+
<Card>
|
|
562
|
+
<form onSubmit={submit}>
|
|
563
|
+
<CardHeader>
|
|
564
|
+
<CardTitle>{operation.name}</CardTitle>
|
|
565
|
+
{operation.description ? (
|
|
566
|
+
<CardDescription>{operation.description}</CardDescription>
|
|
567
|
+
) : null}
|
|
568
|
+
</CardHeader>
|
|
569
|
+
<CardContent className="flex flex-col gap-6">
|
|
570
|
+
<SchemaFields
|
|
571
|
+
schema={operation.inputSchema}
|
|
572
|
+
values={values}
|
|
573
|
+
onChange={change}
|
|
574
|
+
idPrefix={operation.name}
|
|
575
|
+
/>
|
|
576
|
+
<Button type="submit" disabled={busy} className="w-fit">
|
|
577
|
+
Run
|
|
578
|
+
</Button>
|
|
579
|
+
{outcome ? <Preview outcome={outcome} /> : null}
|
|
580
|
+
</CardContent>
|
|
581
|
+
</form>
|
|
582
|
+
</Card>
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The developer's own \`.env\`, edited from the page the kernel is already serving: the kernel
|
|
588
|
+
* answers \`/env\` with the names it declares and which of them have a value, and takes one
|
|
589
|
+
* \`{name, value}\` back. It is the escape hatch for an author working inside an agent harness,
|
|
590
|
+
* where there is no terminal to type a credential into — never the tool's runtime config, which
|
|
591
|
+
* a host injects into the environment.
|
|
592
|
+
*
|
|
593
|
+
* The panel is absent wherever \`/env\` is not answered: a static host (GitHub Pages), a kernel
|
|
594
|
+
* whose project declares no secret, or a build served by anything but the kernel.
|
|
595
|
+
*/
|
|
596
|
+
function SecretsPanel() {
|
|
597
|
+
const [env, setEnv] = useState<{ declared: string[]; present: string[] } | null>(null)
|
|
598
|
+
const [values, setValues] = useState<Record<string, string>>({})
|
|
599
|
+
const [status, setStatus] = useState("")
|
|
600
|
+
const url = new URL("env", document.baseURI).href
|
|
601
|
+
const load = () =>
|
|
602
|
+
fetch(url, { headers: authorization() })
|
|
603
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
|
|
604
|
+
.then((body: { declared: string[]; present: string[] }) => setEnv(body))
|
|
605
|
+
.catch(() => setEnv(null))
|
|
606
|
+
// Once, on mount: the kernel is the only thing that can answer, and it does not change.
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
load()
|
|
609
|
+
}, [])
|
|
610
|
+
if (!env || env.declared.length === 0) return null
|
|
611
|
+
|
|
612
|
+
async function save(name: string) {
|
|
613
|
+
setStatus("")
|
|
614
|
+
const response = await fetch(url, {
|
|
615
|
+
method: "POST",
|
|
616
|
+
headers: { "Content-Type": "application/json", ...authorization() },
|
|
617
|
+
body: JSON.stringify({ name, value: values[name] ?? "" }),
|
|
618
|
+
})
|
|
619
|
+
const body = (await response.json()) as { present?: string[]; error?: string }
|
|
620
|
+
if (!response.ok) return setStatus(body.error ?? \`HTTP \${response.status}\`)
|
|
621
|
+
setValues((current) => ({ ...current, [name]: "" }))
|
|
622
|
+
setStatus(\`\${name} written to .env\`)
|
|
623
|
+
await load()
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return (
|
|
627
|
+
<Card data-slot="secrets">
|
|
628
|
+
<CardHeader>
|
|
629
|
+
<CardTitle>Secrets</CardTitle>
|
|
630
|
+
<CardDescription>
|
|
631
|
+
Written to this checkout's gitignored <code>.env</code>, which the live tests and
|
|
632
|
+
<code> toolfactory bootstrap-repo</code> read. Run <code>toolfactory secrets check</code>{" "}
|
|
633
|
+
to ask each registry whether it accepts them.
|
|
634
|
+
</CardDescription>
|
|
635
|
+
</CardHeader>
|
|
636
|
+
<CardContent className="flex flex-col gap-4">
|
|
637
|
+
{env.declared.map((name) => (
|
|
638
|
+
<Field key={name} orientation="horizontal">
|
|
639
|
+
<FieldLabel htmlFor={\`secret-\${name}\`} className="w-64 shrink-0">
|
|
640
|
+
{name}{" "}
|
|
641
|
+
<Badge variant={env.present.includes(name) ? "secondary" : "outline"}>
|
|
642
|
+
{env.present.includes(name) ? "set" : "missing"}
|
|
643
|
+
</Badge>
|
|
644
|
+
</FieldLabel>
|
|
645
|
+
<Input
|
|
646
|
+
id={\`secret-\${name}\`}
|
|
647
|
+
type="password"
|
|
648
|
+
autoComplete="off"
|
|
649
|
+
placeholder={env.present.includes(name) ? "replace" : "paste the value"}
|
|
650
|
+
value={values[name] ?? ""}
|
|
651
|
+
onChange={(event) => setValues({ ...values, [name]: event.target.value })}
|
|
652
|
+
/>
|
|
653
|
+
<Button
|
|
654
|
+
type="button"
|
|
655
|
+
variant="secondary"
|
|
656
|
+
disabled={!values[name]}
|
|
657
|
+
onClick={() => save(name)}
|
|
658
|
+
>
|
|
659
|
+
Save
|
|
660
|
+
</Button>
|
|
661
|
+
</Field>
|
|
662
|
+
))}
|
|
663
|
+
{status ? (
|
|
664
|
+
<p data-slot="secrets-status" className="text-sm text-muted-foreground">
|
|
665
|
+
{status}
|
|
666
|
+
</p>
|
|
667
|
+
) : null}
|
|
668
|
+
</CardContent>
|
|
669
|
+
</Card>
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The generated operations page: one form per operation over \`ops.json\`, wired to \`/mcp\`. */
|
|
674
|
+
export function OperationsPage() {
|
|
675
|
+
const [endpoint, setEndpoint] = useState(DEFAULT_ENDPOINT)
|
|
676
|
+
const [active, setActive] = useState(DATA.operations[0]?.name ?? "")
|
|
677
|
+
const activeOperation = DATA.operations.find((operation) => operation.name === active)
|
|
678
|
+
|
|
679
|
+
return (
|
|
680
|
+
<div className="flex flex-col gap-6">
|
|
681
|
+
<SecretsPanel />
|
|
682
|
+
<Field>
|
|
683
|
+
<FieldLabel htmlFor="endpoint">MCP endpoint</FieldLabel>
|
|
684
|
+
<Input
|
|
685
|
+
id="endpoint"
|
|
686
|
+
value={endpoint}
|
|
687
|
+
placeholder={DEFAULT_ENDPOINT}
|
|
688
|
+
onChange={(event) => setEndpoint(event.target.value)}
|
|
689
|
+
/>
|
|
690
|
+
</Field>
|
|
691
|
+
|
|
692
|
+
{activeOperation ? (
|
|
693
|
+
<div className="flex flex-col gap-6 md:flex-row">
|
|
694
|
+
<ToggleGroup
|
|
695
|
+
orientation="vertical"
|
|
696
|
+
className="md:w-44"
|
|
697
|
+
value={[activeOperation.name]}
|
|
698
|
+
onValueChange={(names) => names[0] && setActive(String(names[0]))}
|
|
699
|
+
>
|
|
700
|
+
{DATA.operations.map((operation) => (
|
|
701
|
+
<ToggleGroupItem key={operation.name} value={operation.name} className="justify-start">
|
|
702
|
+
{operation.name}
|
|
703
|
+
</ToggleGroupItem>
|
|
704
|
+
))}
|
|
705
|
+
</ToggleGroup>
|
|
706
|
+
<div className="min-w-0 flex-1">
|
|
707
|
+
<OperationCard
|
|
708
|
+
key={activeOperation.name}
|
|
709
|
+
operation={activeOperation}
|
|
710
|
+
endpoint={endpoint}
|
|
711
|
+
/>
|
|
712
|
+
</div>
|
|
713
|
+
</div>
|
|
714
|
+
) : (
|
|
715
|
+
<Empty>
|
|
716
|
+
<EmptyHeader>
|
|
717
|
+
<EmptyTitle>No operations on this surface</EmptyTitle>
|
|
718
|
+
<EmptyDescription>
|
|
719
|
+
Every operation needs a host capability a web page cannot provide.
|
|
720
|
+
</EmptyDescription>
|
|
721
|
+
</EmptyHeader>
|
|
722
|
+
</Empty>
|
|
723
|
+
)}
|
|
724
|
+
|
|
725
|
+
{DATA.excluded.length > 0 ? (
|
|
726
|
+
<Alert>
|
|
727
|
+
<AlertTitle>
|
|
728
|
+
{DATA.excluded.length} operation(s) are not reachable over MCP
|
|
729
|
+
</AlertTitle>
|
|
730
|
+
<AlertDescription>
|
|
731
|
+
<div className="flex flex-wrap gap-2">
|
|
732
|
+
{DATA.excluded.map((operation) => (
|
|
733
|
+
<Badge key={operation.name} variant="outline">
|
|
734
|
+
{operation.name} — {operation.reason ?? "excluded"}
|
|
735
|
+
</Badge>
|
|
736
|
+
))}
|
|
737
|
+
</div>
|
|
738
|
+
</AlertDescription>
|
|
739
|
+
</Alert>
|
|
740
|
+
) : null}
|
|
741
|
+
</div>
|
|
742
|
+
)
|
|
743
|
+
}
|
|
744
|
+
`;
|
|
745
|
+
/**
|
|
746
|
+
* The template's tail, written once when `src/App.tsx` does not exist yet and never touched
|
|
747
|
+
* again: the `pages` array and the shell that renders whichever one is active. `OperationsPage`,
|
|
748
|
+
* every shadcn import it needs, and `DATA` all come from the generated region above — nothing
|
|
749
|
+
* here imports anything of its own.
|
|
750
|
+
*/
|
|
751
|
+
const APP_TEMPLATE_TAIL = `
|
|
752
|
+
export const pages: { id: string; title: string; element: ReactNode }[] = [
|
|
753
|
+
{ id: "operations", title: "Operations", element: <OperationsPage /> },
|
|
754
|
+
// Add your own pages here — e.g. a d3 graph in src/pages/planner.tsx, imported above:
|
|
755
|
+
// import { PlannerPage } from "./pages/planner"
|
|
756
|
+
// { id: "planner", title: "Degree planner", element: <PlannerPage /> },
|
|
757
|
+
]
|
|
758
|
+
|
|
759
|
+
export default function App() {
|
|
760
|
+
const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark"))
|
|
761
|
+
const [active, setActive] = useState(pages[0]?.id ?? "")
|
|
762
|
+
useEffect(() => {
|
|
763
|
+
document.documentElement.classList.toggle("dark", dark)
|
|
764
|
+
}, [dark])
|
|
765
|
+
const page = pages.find((candidate) => candidate.id === active) ?? pages[0]
|
|
766
|
+
|
|
767
|
+
return (
|
|
768
|
+
<div className="min-h-svh bg-background text-foreground">
|
|
769
|
+
<header className="border-b">
|
|
770
|
+
<div className="mx-auto flex max-w-5xl flex-col gap-4 px-6 py-6">
|
|
771
|
+
<div className="flex items-center gap-3">
|
|
772
|
+
<h1 className="text-xl font-semibold">{DATA.tool.name}</h1>
|
|
773
|
+
{DATA.tool.version ? <Badge variant="secondary">{DATA.tool.version}</Badge> : null}
|
|
774
|
+
{pages.length > 1 ? (
|
|
775
|
+
<nav className="flex gap-1">
|
|
776
|
+
{pages.map((candidate) => (
|
|
777
|
+
<Button
|
|
778
|
+
key={candidate.id}
|
|
779
|
+
variant={candidate.id === active ? "secondary" : "ghost"}
|
|
780
|
+
size="sm"
|
|
781
|
+
onClick={() => setActive(candidate.id)}
|
|
782
|
+
>
|
|
783
|
+
{candidate.title}
|
|
784
|
+
</Button>
|
|
785
|
+
))}
|
|
786
|
+
</nav>
|
|
787
|
+
) : null}
|
|
788
|
+
<Button
|
|
789
|
+
variant="ghost"
|
|
790
|
+
size="icon"
|
|
791
|
+
className="ml-auto"
|
|
792
|
+
aria-label="Toggle theme"
|
|
793
|
+
onClick={() => setDark(!dark)}
|
|
794
|
+
>
|
|
795
|
+
{dark ? <SunIcon /> : <MoonIcon />}
|
|
796
|
+
</Button>
|
|
797
|
+
</div>
|
|
798
|
+
{DATA.tool.description ? (
|
|
799
|
+
<p className="text-sm text-muted-foreground">{DATA.tool.description}</p>
|
|
800
|
+
) : null}
|
|
801
|
+
</div>
|
|
802
|
+
</header>
|
|
803
|
+
|
|
804
|
+
<main className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-6">{page?.element}</main>
|
|
805
|
+
</div>
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
`;
|
|
809
|
+
const SCHEMA_FORM_TSX = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
810
|
+
import {
|
|
811
|
+
Field,
|
|
812
|
+
FieldDescription,
|
|
813
|
+
FieldGroup,
|
|
814
|
+
FieldLabel,
|
|
815
|
+
} from "@/components/ui/field"
|
|
816
|
+
import { Input } from "@/components/ui/input"
|
|
817
|
+
import {
|
|
818
|
+
Select,
|
|
819
|
+
SelectContent,
|
|
820
|
+
SelectGroup,
|
|
821
|
+
SelectItem,
|
|
822
|
+
SelectTrigger,
|
|
823
|
+
SelectValue,
|
|
824
|
+
} from "@/components/ui/select"
|
|
825
|
+
import { Switch } from "@/components/ui/switch"
|
|
826
|
+
import { Textarea } from "@/components/ui/textarea"
|
|
827
|
+
|
|
828
|
+
export interface JsonSchema {
|
|
829
|
+
type?: string
|
|
830
|
+
description?: string
|
|
831
|
+
enum?: (string | number)[]
|
|
832
|
+
items?: { type?: string }
|
|
833
|
+
properties?: Record<string, JsonSchema>
|
|
834
|
+
required?: string[]
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export type FieldValue = string | boolean | null
|
|
838
|
+
export type FormValues = Record<string, FieldValue>
|
|
839
|
+
|
|
840
|
+
type Control = "switch" | "select" | "number" | "list" | "json" | "text"
|
|
841
|
+
|
|
842
|
+
/** One control per property, chosen exactly as the generated CLI chooses a flag. */
|
|
843
|
+
function control(property: JsonSchema): Control {
|
|
844
|
+
if (property.enum) return "select"
|
|
845
|
+
if (property.type === "boolean") return "switch"
|
|
846
|
+
if (property.type === "number" || property.type === "integer") return "number"
|
|
847
|
+
if (property.type === "array") return property.items?.type === "string" ? "list" : "json"
|
|
848
|
+
if (property.type === "string") return "text"
|
|
849
|
+
return "json"
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const HINT: Partial<Record<Control, string>> = {
|
|
853
|
+
list: "comma-separated",
|
|
854
|
+
json: "JSON",
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function properties(schema: JsonSchema): [string, JsonSchema][] {
|
|
858
|
+
return Object.entries(schema.properties ?? {})
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export function initialValues(schema: JsonSchema): FormValues {
|
|
862
|
+
return Object.fromEntries(
|
|
863
|
+
properties(schema).map(([name, property]) => {
|
|
864
|
+
const kind = control(property)
|
|
865
|
+
return [name, kind === "switch" ? false : kind === "select" ? null : ""]
|
|
866
|
+
}),
|
|
867
|
+
)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** The filled-in controls as JSON arguments, coerced the way the CLI coerces its flags. */
|
|
871
|
+
export function toArguments(schema: JsonSchema, values: FormValues): Record<string, unknown> {
|
|
872
|
+
const entries = properties(schema).flatMap(([name, property]) => {
|
|
873
|
+
const value = values[name]
|
|
874
|
+
if (value === null || value === "") return []
|
|
875
|
+
if (typeof value === "boolean") return value ? [[name, true] as const] : []
|
|
876
|
+
switch (control(property)) {
|
|
877
|
+
case "number":
|
|
878
|
+
return [[name, Number(value)] as const]
|
|
879
|
+
case "list":
|
|
880
|
+
return [[name, value.split(",").map((item) => item.trim()).filter(Boolean)] as const]
|
|
881
|
+
case "json":
|
|
882
|
+
return [[name, JSON.parse(value) as unknown] as const]
|
|
883
|
+
default:
|
|
884
|
+
return [[name, value] as const]
|
|
885
|
+
}
|
|
886
|
+
})
|
|
887
|
+
return Object.fromEntries(entries)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export function SchemaFields({
|
|
891
|
+
schema,
|
|
892
|
+
values,
|
|
893
|
+
onChange,
|
|
894
|
+
idPrefix,
|
|
895
|
+
}: {
|
|
896
|
+
schema: JsonSchema
|
|
897
|
+
values: FormValues
|
|
898
|
+
onChange: (name: string, value: FieldValue) => void
|
|
899
|
+
idPrefix: string
|
|
900
|
+
}) {
|
|
901
|
+
const required = new Set(schema.required ?? [])
|
|
902
|
+
return (
|
|
903
|
+
<FieldGroup>
|
|
904
|
+
{properties(schema).map(([name, property]) => {
|
|
905
|
+
const kind = control(property)
|
|
906
|
+
const id = \`\${idPrefix}-\${name}\`
|
|
907
|
+
const hint = HINT[kind]
|
|
908
|
+
const description = [property.description, hint && \`(\${hint})\`].filter(Boolean).join(" ")
|
|
909
|
+
const label = (
|
|
910
|
+
<FieldLabel htmlFor={id}>
|
|
911
|
+
{name}
|
|
912
|
+
{required.has(name) ? <span className="text-destructive">*</span> : null}
|
|
913
|
+
</FieldLabel>
|
|
914
|
+
)
|
|
915
|
+
const note = description ? <FieldDescription>{description}</FieldDescription> : null
|
|
916
|
+
if (kind === "switch") {
|
|
917
|
+
return (
|
|
918
|
+
<Field key={name} orientation="horizontal">
|
|
919
|
+
<Switch
|
|
920
|
+
id={id}
|
|
921
|
+
checked={values[name] === true}
|
|
922
|
+
onCheckedChange={(checked) => onChange(name, checked)}
|
|
923
|
+
/>
|
|
924
|
+
{label}
|
|
925
|
+
{note}
|
|
926
|
+
</Field>
|
|
927
|
+
)
|
|
928
|
+
}
|
|
929
|
+
if (kind === "select") {
|
|
930
|
+
const items = [
|
|
931
|
+
{ label: \`Select \${name}\`, value: null },
|
|
932
|
+
...(property.enum ?? []).map((option) => ({ label: String(option), value: String(option) })),
|
|
933
|
+
]
|
|
934
|
+
return (
|
|
935
|
+
<Field key={name}>
|
|
936
|
+
{label}
|
|
937
|
+
<Select
|
|
938
|
+
items={items}
|
|
939
|
+
value={values[name] as string | null}
|
|
940
|
+
onValueChange={(value) => onChange(name, value as FieldValue)}
|
|
941
|
+
>
|
|
942
|
+
<SelectTrigger id={id}>
|
|
943
|
+
<SelectValue />
|
|
944
|
+
</SelectTrigger>
|
|
945
|
+
<SelectContent>
|
|
946
|
+
<SelectGroup>
|
|
947
|
+
{items.map((item) => (
|
|
948
|
+
<SelectItem key={String(item.value)} value={item.value}>
|
|
949
|
+
{item.label}
|
|
950
|
+
</SelectItem>
|
|
951
|
+
))}
|
|
952
|
+
</SelectGroup>
|
|
953
|
+
</SelectContent>
|
|
954
|
+
</Select>
|
|
955
|
+
{note}
|
|
956
|
+
</Field>
|
|
957
|
+
)
|
|
958
|
+
}
|
|
959
|
+
if (kind === "json") {
|
|
960
|
+
return (
|
|
961
|
+
<Field key={name}>
|
|
962
|
+
{label}
|
|
963
|
+
<Textarea
|
|
964
|
+
id={id}
|
|
965
|
+
value={String(values[name] ?? "")}
|
|
966
|
+
onChange={(event) => onChange(name, event.target.value)}
|
|
967
|
+
/>
|
|
968
|
+
{note}
|
|
969
|
+
</Field>
|
|
970
|
+
)
|
|
971
|
+
}
|
|
972
|
+
return (
|
|
973
|
+
<Field key={name}>
|
|
974
|
+
{label}
|
|
975
|
+
<Input
|
|
976
|
+
id={id}
|
|
977
|
+
type={kind === "number" ? "number" : "text"}
|
|
978
|
+
value={String(values[name] ?? "")}
|
|
979
|
+
onChange={(event) => onChange(name, event.target.value)}
|
|
980
|
+
/>
|
|
981
|
+
{note}
|
|
982
|
+
</Field>
|
|
983
|
+
)
|
|
984
|
+
})}
|
|
985
|
+
</FieldGroup>
|
|
986
|
+
)
|
|
987
|
+
}
|
|
988
|
+
`;
|
|
989
|
+
/**
|
|
990
|
+
* Playwright smoke for the generated page, driven the way a user runs it: `mcp --http` serves
|
|
991
|
+
* `web/dist` beside the MCP endpoint on one port, so the page is loaded from the very origin it
|
|
992
|
+
* calls, with the token in the fragment exactly as `--open` hands it over. The first read-only
|
|
993
|
+
* operation is then submitted for real and its result has to come back; an operation that is
|
|
994
|
+
* not read-only is never run, because this is the author's own project.
|
|
995
|
+
*
|
|
996
|
+
* Run `npm --prefix web run build` first; the kernel serves what it wrote.
|
|
997
|
+
*/
|
|
998
|
+
function smokeMjs(project) {
|
|
999
|
+
const { command, args } = getBinding(project.tool.binding).kernelCommand(project);
|
|
1000
|
+
return `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
1001
|
+
import { spawn } from "node:child_process"
|
|
1002
|
+
import { readFile } from "node:fs/promises"
|
|
1003
|
+
import { chromium } from "playwright"
|
|
1004
|
+
|
|
1005
|
+
const page = JSON.parse(await readFile(new URL("./src/ops.json", import.meta.url), "utf8"))
|
|
1006
|
+
const KERNEL = ${JSON.stringify([command, ...args])}
|
|
1007
|
+
// A token of this run's own, so the smoke never depends on whether the author has paired, and
|
|
1008
|
+
// the fragment-to-bearer path the page uses is what gets exercised.
|
|
1009
|
+
const TOKEN = \`smoke-\${Math.random().toString(36).slice(2)}\`
|
|
1010
|
+
|
|
1011
|
+
const kernel = spawn(KERNEL[0], [...KERNEL.slice(1), "--http", "0"], {
|
|
1012
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
1013
|
+
env: { ...process.env, ${JSON.stringify(`${envName(project.identity.name)}_MCP_TOKEN`)}: TOKEN },
|
|
1014
|
+
})
|
|
1015
|
+
const origin = await new Promise((resolve, reject) => {
|
|
1016
|
+
let output = ""
|
|
1017
|
+
kernel.stderr.setEncoding("utf8")
|
|
1018
|
+
kernel.stderr.on("data", (chunk) => {
|
|
1019
|
+
output += chunk
|
|
1020
|
+
const serving = /Serving MCP streamable HTTP on (\\S+)/.exec(output)
|
|
1021
|
+
if (serving) resolve(new URL("/", serving[1]).href)
|
|
1022
|
+
})
|
|
1023
|
+
kernel.on("exit", (code) => reject(new Error(\`the kernel exited with \${code}: \${output}\`)))
|
|
1024
|
+
})
|
|
1025
|
+
console.log(\`PASS the kernel serves \${origin}\`)
|
|
1026
|
+
|
|
1027
|
+
// Read-only, because clicking Run here calls the real operation against the real project.
|
|
1028
|
+
const operation = page.operations.find((candidate) => candidate.annotations?.readOnlyHint)
|
|
1029
|
+
if (!operation) {
|
|
1030
|
+
console.log("PASS no read-only operation to round-trip; nothing to smoke")
|
|
1031
|
+
kernel.kill()
|
|
1032
|
+
process.exit(0)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const browser = await chromium.launch()
|
|
1036
|
+
try {
|
|
1037
|
+
const tab = await browser.newPage()
|
|
1038
|
+
const failures = []
|
|
1039
|
+
tab.on("pageerror", (error) => failures.push(error.message))
|
|
1040
|
+
await tab.goto(\`\${origin}#\${TOKEN}\`)
|
|
1041
|
+
|
|
1042
|
+
await tab.getByRole("button", { name: operation.name, exact: true }).click()
|
|
1043
|
+
const expected = Object.keys(operation.inputSchema.properties ?? {})
|
|
1044
|
+
for (const name of expected) {
|
|
1045
|
+
await tab.locator(\`[id="\${operation.name}-\${name}"]\`).waitFor({ state: "visible" })
|
|
1046
|
+
}
|
|
1047
|
+
console.log(\`PASS \${operation.name} renders \${expected.length} field(s): \${expected.join(", ")}\`)
|
|
1048
|
+
|
|
1049
|
+
// Only a required plain string: anything with a default is already the value that works.
|
|
1050
|
+
const filled = (operation.inputSchema.required ?? []).find((name) => {
|
|
1051
|
+
const property = operation.inputSchema.properties[name]
|
|
1052
|
+
return property.type === "string" && !property.enum
|
|
1053
|
+
})
|
|
1054
|
+
if (filled) await tab.locator(\`[id="\${operation.name}-\${filled}"]\`).fill("smoke")
|
|
1055
|
+
|
|
1056
|
+
await tab.getByRole("button", { name: "Run" }).click()
|
|
1057
|
+
|
|
1058
|
+
if (page.cliAvailable) {
|
|
1059
|
+
const cli = tab.locator('pre[data-slot="cli"]')
|
|
1060
|
+
await cli.waitFor({ state: "visible" })
|
|
1061
|
+
const text = (await cli.textContent()).trim()
|
|
1062
|
+
if (filled && !text.includes(\`"\${filled}":"smoke"\`)) {
|
|
1063
|
+
throw new Error(\`cli preview lost the \${filled} argument: \${text}\`)
|
|
1064
|
+
}
|
|
1065
|
+
console.log(\`PASS cli preview: \${text}\`)
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// The round trip: same origin, bearer from the fragment, a real tools/call answered by the
|
|
1069
|
+
// kernel that served this page.
|
|
1070
|
+
await tab.getByRole("tab", { name: "Result" }).click()
|
|
1071
|
+
const result = tab.locator('pre[data-slot="result"]')
|
|
1072
|
+
await result.waitFor({ state: "visible" })
|
|
1073
|
+
if ((await result.getAttribute("data-failed")) === "true") {
|
|
1074
|
+
throw new Error(\`\${operation.name} failed: \${(await result.textContent()).trim()}\`)
|
|
1075
|
+
}
|
|
1076
|
+
console.log(\`PASS \${operation.name} round-tripped: \${(await result.textContent()).trim().slice(0, 200)}\`)
|
|
1077
|
+
|
|
1078
|
+
if (failures.length) throw new Error(\`page raised: \${failures.join("; ")}\`)
|
|
1079
|
+
console.log("PASS no uncaught page errors")
|
|
1080
|
+
} finally {
|
|
1081
|
+
await browser.close()
|
|
1082
|
+
kernel.kill()
|
|
1083
|
+
}
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
const VITE_CONFIG_TS = `// Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate.
|
|
1087
|
+
import path from "path"
|
|
1088
|
+
import tailwindcss from "@tailwindcss/vite"
|
|
1089
|
+
import react from "@vitejs/plugin-react"
|
|
1090
|
+
import { defineConfig } from "vite"
|
|
1091
|
+
|
|
1092
|
+
// Where \`<tool> mcp --http\` listens. The dev server proxies /mcp there so the page POSTs
|
|
1093
|
+
// same-origin (no CORS), and changeOrigin rewrites Host so the kernel's loopback guard passes.
|
|
1094
|
+
const mcp = new URL(process.env.VITE_MCP_URL ?? "http://localhost:3000/mcp")
|
|
1095
|
+
|
|
1096
|
+
// https://vite.dev/config/
|
|
1097
|
+
export default defineConfig({
|
|
1098
|
+
// Relative, so the one build is served the same wherever it is mounted: at "/" by
|
|
1099
|
+
// \`mcp --http\`, under a plugin route's prefix in the OpenClaw Control UI, and under
|
|
1100
|
+
// /<repo>/ on GitHub Pages. PAGES_BASE stays an override for a deployment that needs an
|
|
1101
|
+
// absolute one; the dev server is always "/".
|
|
1102
|
+
base: process.env.PAGES_BASE ?? "./",
|
|
1103
|
+
plugins: [react(), tailwindcss()],
|
|
1104
|
+
resolve: {
|
|
1105
|
+
alias: {
|
|
1106
|
+
"@": path.resolve(import.meta.dirname, "./src"),
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
server: {
|
|
1110
|
+
proxy: {
|
|
1111
|
+
"/mcp": { target: mcp.origin, changeOrigin: true, rewrite: () => mcp.pathname },
|
|
1112
|
+
},
|
|
1113
|
+
},
|
|
1114
|
+
})
|
|
1115
|
+
`;
|
|
1116
|
+
/**
|
|
1117
|
+
* The documentation's tsconfig steps, minus `baseUrl`: the TypeScript the Vite scaffold pins
|
|
1118
|
+
* (6.x) errors on that option, and `paths` alone resolves relative to the config file.
|
|
1119
|
+
*/
|
|
1120
|
+
function tsconfigWithAlias() {
|
|
1121
|
+
return WEB_SCAFFOLD.vite["tsconfig.json"].replace("\n ]\n}", '\n ],\n "compilerOptions": {\n "paths": {\n "@/*": ["./src/*"]\n }\n }\n}');
|
|
1122
|
+
}
|
|
1123
|
+
function tsconfigAppWithAlias() {
|
|
1124
|
+
return WEB_SCAFFOLD.vite["tsconfig.app.json"].replace('"compilerOptions": {', '"compilerOptions": {\n "paths": {\n "@/*": ["./src/*"]\n },');
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* `web/package.json` is a MergeFile, not a FullFile: an author page that needs its own
|
|
1128
|
+
* dependency (`npm --prefix web install d3`, say) survives the next `toolfactory build`
|
|
1129
|
+
* because deepMerge only overwrites the keys below, leaving an added `dependencies.d3` alone.
|
|
1130
|
+
*/
|
|
1131
|
+
function packageJsonPatch(project) {
|
|
1132
|
+
return {
|
|
1133
|
+
name: `${project.identity.name}-web`,
|
|
1134
|
+
private: true,
|
|
1135
|
+
version: project.identity.version ?? "0.0.0",
|
|
1136
|
+
type: "module",
|
|
1137
|
+
scripts: { ...WEB_SCAFFOLD.packageJson.scripts, smoke: "node smoke.mjs" },
|
|
1138
|
+
dependencies: WEB_SCAFFOLD.packageJson.dependencies,
|
|
1139
|
+
devDependencies: {
|
|
1140
|
+
...WEB_SCAFFOLD.packageJson.devDependencies,
|
|
1141
|
+
playwright: WEB_SCAFFOLD.playwright,
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function indexHtml(project) {
|
|
1146
|
+
return `<!doctype html>
|
|
1147
|
+
<!-- Generated by toolfactory. Do not edit; run \`toolfactory build\` to regenerate. -->
|
|
1148
|
+
<html lang="en">
|
|
1149
|
+
<head>
|
|
1150
|
+
<meta charset="UTF-8" />
|
|
1151
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1152
|
+
<title>${project.identity.name} — operations</title>
|
|
1153
|
+
</head>
|
|
1154
|
+
<body>
|
|
1155
|
+
<div id="root"></div>
|
|
1156
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
1157
|
+
</body>
|
|
1158
|
+
</html>
|
|
1159
|
+
`;
|
|
1160
|
+
}
|
|
1161
|
+
/** Everything the page reads: the operations this surface carries, and why the others are missing. */
|
|
1162
|
+
function opsJson(project, operations) {
|
|
1163
|
+
const included = new Set(operations.map((operation) => operation.name));
|
|
1164
|
+
return json({
|
|
1165
|
+
tool: {
|
|
1166
|
+
name: project.identity.name,
|
|
1167
|
+
version: project.identity.version,
|
|
1168
|
+
description: project.identity.description,
|
|
1169
|
+
},
|
|
1170
|
+
cliAvailable: has(project, "cli"),
|
|
1171
|
+
defaultEndpoint: ENDPOINT_PATH,
|
|
1172
|
+
mcpProtocolVersion: MCP_PROTOCOL_VERSION,
|
|
1173
|
+
operations: operations.map((operation) => ({
|
|
1174
|
+
name: operation.name,
|
|
1175
|
+
description: operation.description,
|
|
1176
|
+
inputSchema: operation.inputSchema,
|
|
1177
|
+
// The tool contract's own hints, as MCP carries them; `smoke.mjs` only ever runs a
|
|
1178
|
+
// `readOnlyHint` operation, because it runs it against the author's real project.
|
|
1179
|
+
annotations: operation.annotations,
|
|
1180
|
+
})),
|
|
1181
|
+
excluded: project.operations
|
|
1182
|
+
.filter((operation) => !included.has(operation.name))
|
|
1183
|
+
.map((operation) => ({ name: operation.name, reason: mcpVerdict(operation).reason })),
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
export const surface = {
|
|
1187
|
+
id: "web",
|
|
1188
|
+
plan(project) {
|
|
1189
|
+
const operations = includedOperations(project, surface);
|
|
1190
|
+
const file = (path, content, output) => ({
|
|
1191
|
+
kind: "file",
|
|
1192
|
+
path: `${WEB_DIR}/${path}`,
|
|
1193
|
+
content,
|
|
1194
|
+
...(output ? { output } : {}),
|
|
1195
|
+
});
|
|
1196
|
+
return [
|
|
1197
|
+
file(".oxlintrc.json", WEB_SCAFFOLD.vite[".oxlintrc.json"]),
|
|
1198
|
+
file("components.json", json(WEB_SCAFFOLD.componentsJson)),
|
|
1199
|
+
file("index.html", indexHtml(project)),
|
|
1200
|
+
{
|
|
1201
|
+
kind: "merge",
|
|
1202
|
+
path: `${WEB_DIR}/package.json`,
|
|
1203
|
+
format: "json",
|
|
1204
|
+
patch: packageJsonPatch(project),
|
|
1205
|
+
},
|
|
1206
|
+
file("smoke.mjs", smokeMjs(project)),
|
|
1207
|
+
{
|
|
1208
|
+
kind: "region",
|
|
1209
|
+
path: `${WEB_DIR}/src/App.tsx`,
|
|
1210
|
+
regions: [{ begin: APP_BEGIN, end: APP_END, content: APP_REGION }],
|
|
1211
|
+
template: `${APP_BEGIN}${APP_END}\n${APP_TEMPLATE_TAIL}`,
|
|
1212
|
+
},
|
|
1213
|
+
file("src/index.css", WEB_SCAFFOLD.indexCss),
|
|
1214
|
+
file("src/lib/utils.ts", WEB_SCAFFOLD.utils),
|
|
1215
|
+
file("src/main.tsx", MAIN_TSX),
|
|
1216
|
+
// A lossy duplicate of dev.toolfactory/ops.json rebuilt by `npm run build`; not tracked (D8).
|
|
1217
|
+
file("src/ops.json", opsJson(project, operations), true),
|
|
1218
|
+
file("src/schema-form.tsx", SCHEMA_FORM_TSX),
|
|
1219
|
+
file("tsconfig.app.json", tsconfigAppWithAlias()),
|
|
1220
|
+
file("tsconfig.json", tsconfigWithAlias()),
|
|
1221
|
+
file("tsconfig.node.json", WEB_SCAFFOLD.vite["tsconfig.node.json"]),
|
|
1222
|
+
file("vite.config.ts", VITE_CONFIG_TS),
|
|
1223
|
+
];
|
|
1224
|
+
},
|
|
1225
|
+
validate(project) {
|
|
1226
|
+
const at = { cwd: project.root };
|
|
1227
|
+
return [
|
|
1228
|
+
{
|
|
1229
|
+
label: "npm install (web)",
|
|
1230
|
+
command: "npm",
|
|
1231
|
+
args: ["--prefix", WEB_DIR, "install"],
|
|
1232
|
+
...at,
|
|
1233
|
+
},
|
|
1234
|
+
// The smoke drives the Chromium the project's own playwright expects; `install` is a
|
|
1235
|
+
// no-op once that revision is present, so this costs nothing on a warm machine.
|
|
1236
|
+
{
|
|
1237
|
+
label: "playwright install chromium (web)",
|
|
1238
|
+
command: "npm",
|
|
1239
|
+
args: ["--prefix", WEB_DIR, "exec", "--no", "--", "playwright", "install", "chromium"],
|
|
1240
|
+
...at,
|
|
1241
|
+
},
|
|
1242
|
+
// The components under src/components/ui are the shadcn CLI's files, not toolfactory's;
|
|
1243
|
+
// run the shadcn the project pins rather than whatever is on PATH. `add` writes the ones
|
|
1244
|
+
// that are missing and leaves an unmodified copy of the others alone.
|
|
1245
|
+
{
|
|
1246
|
+
label: "shadcn add (web)",
|
|
1247
|
+
command: "npm",
|
|
1248
|
+
args: [
|
|
1249
|
+
"--prefix",
|
|
1250
|
+
WEB_DIR,
|
|
1251
|
+
"exec",
|
|
1252
|
+
"--no",
|
|
1253
|
+
"--",
|
|
1254
|
+
"shadcn",
|
|
1255
|
+
"add",
|
|
1256
|
+
"--yes",
|
|
1257
|
+
"--cwd",
|
|
1258
|
+
WEB_DIR,
|
|
1259
|
+
...WEB_SCAFFOLD.components,
|
|
1260
|
+
],
|
|
1261
|
+
...at,
|
|
1262
|
+
},
|
|
1263
|
+
// C2: the scaffold is mirrored by running the real generators, never by trusting constants.
|
|
1264
|
+
{
|
|
1265
|
+
label: "web scaffold drift",
|
|
1266
|
+
command: "node",
|
|
1267
|
+
args: [...execArgv, DRIFT_ENTRY, project.root],
|
|
1268
|
+
...at,
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
label: "vite build (web)",
|
|
1272
|
+
command: "npm",
|
|
1273
|
+
args: ["--prefix", WEB_DIR, "run", "build"],
|
|
1274
|
+
...at,
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
label: "playwright smoke (web)",
|
|
1278
|
+
command: "node",
|
|
1279
|
+
args: [`${WEB_DIR}/smoke.mjs`],
|
|
1280
|
+
...at,
|
|
1281
|
+
},
|
|
1282
|
+
];
|
|
1283
|
+
},
|
|
1284
|
+
verdict: mcpVerdict,
|
|
1285
|
+
};
|