toolfactory 0.1.2 → 0.2.0

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.
Files changed (98) hide show
  1. package/README.md +24 -1
  2. package/dist/commands.d.ts +1 -1
  3. package/dist/distribution/native.d.ts +50 -0
  4. package/dist/distribution/native.js +414 -0
  5. package/dist/distribution/native.test.d.ts +1 -0
  6. package/dist/distribution/native.test.js +127 -0
  7. package/dist/distribution/system.d.ts +57 -0
  8. package/dist/distribution/system.js +637 -0
  9. package/dist/distribution/system.test.d.ts +1 -0
  10. package/dist/distribution/system.test.js +116 -0
  11. package/dist/e2e.test.js +7 -1
  12. package/dist/hosts/openclaw.js +6 -2
  13. package/dist/hosts/vscode.d.ts +1 -0
  14. package/dist/hosts/vscode.js +67 -0
  15. package/dist/model.d.ts +62 -2
  16. package/dist/model.js +9 -0
  17. package/dist/ops.d.ts +52 -0
  18. package/dist/project/apply.d.ts +1 -1
  19. package/dist/project/apply.js +194 -17
  20. package/dist/project/apply.test.js +103 -0
  21. package/dist/project/gate.d.ts +17 -2
  22. package/dist/project/gate.js +68 -3
  23. package/dist/project/history.d.ts +29 -1
  24. package/dist/project/lock.d.ts +5 -0
  25. package/dist/project/lock.js +9 -0
  26. package/dist/surfaces/agent-plugins.js +5 -4
  27. package/dist/surfaces/agents.js +21 -3
  28. package/dist/surfaces/agents.test.js +12 -0
  29. package/dist/surfaces/claude.js +8 -3
  30. package/dist/surfaces/codex.js +8 -3
  31. package/dist/surfaces/codex.test.js +8 -8
  32. package/dist/surfaces/cursor.js +2 -2
  33. package/dist/surfaces/distribution.test.js +3 -3
  34. package/dist/surfaces/dsh.js +25 -6
  35. package/dist/surfaces/dsh.test.js +8 -3
  36. package/dist/surfaces/gemini.js +2 -2
  37. package/dist/surfaces/gemini.test.js +4 -4
  38. package/dist/surfaces/hermes-native.js +38 -9
  39. package/dist/surfaces/hermes-native.test.js +32 -11
  40. package/dist/surfaces/mcp-registry.js +43 -5
  41. package/dist/surfaces/mcp-registry.test.js +18 -0
  42. package/dist/surfaces/openclaw-native.js +10 -8
  43. package/dist/surfaces/openclaw-native.test.js +30 -0
  44. package/dist/surfaces/readme.d.ts +16 -0
  45. package/dist/surfaces/readme.js +7 -0
  46. package/dist/surfaces/registry.js +6 -0
  47. package/dist/surfaces/vscode-extension.d.ts +14 -0
  48. package/dist/surfaces/vscode-extension.js +312 -0
  49. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  50. package/dist/surfaces/vscode-extension.test.js +81 -0
  51. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  52. package/dist/surfaces/vscode-scaffold.js +43 -0
  53. package/dist/surfaces/workflows.js +49 -10
  54. package/dist/surfaces/workflows.test.js +3 -2
  55. package/dist/toolfactory/cli.js +1 -1
  56. package/dist/toolfactory/mcp.js +1 -1
  57. package/package.json +2 -1
  58. package/src/distribution/native.test.ts +161 -0
  59. package/src/distribution/native.ts +460 -0
  60. package/src/distribution/system.test.ts +136 -0
  61. package/src/distribution/system.ts +688 -0
  62. package/src/e2e.test.ts +7 -1
  63. package/src/hosts/openclaw.ts +4 -2
  64. package/src/hosts/vscode.ts +79 -0
  65. package/src/model.ts +15 -1
  66. package/src/project/apply.test.ts +120 -0
  67. package/src/project/apply.ts +257 -15
  68. package/src/project/gate.ts +96 -4
  69. package/src/project/lock.ts +12 -0
  70. package/src/surfaces/agent-plugins.ts +5 -4
  71. package/src/surfaces/agents.test.ts +16 -0
  72. package/src/surfaces/agents.ts +31 -3
  73. package/src/surfaces/claude.ts +7 -3
  74. package/src/surfaces/codex.test.ts +7 -7
  75. package/src/surfaces/codex.ts +8 -3
  76. package/src/surfaces/cursor.ts +2 -10
  77. package/src/surfaces/distribution.test.ts +2 -2
  78. package/src/surfaces/dsh.test.ts +8 -2
  79. package/src/surfaces/dsh.ts +26 -6
  80. package/src/surfaces/gemini.test.ts +3 -3
  81. package/src/surfaces/gemini.ts +1 -2
  82. package/src/surfaces/hermes-native.test.ts +39 -10
  83. package/src/surfaces/hermes-native.ts +41 -11
  84. package/src/surfaces/mcp-registry.test.ts +20 -0
  85. package/src/surfaces/mcp-registry.ts +46 -6
  86. package/src/surfaces/openclaw-native.test.ts +37 -0
  87. package/src/surfaces/openclaw-native.ts +49 -47
  88. package/src/surfaces/readme.ts +10 -0
  89. package/src/surfaces/registry.ts +6 -0
  90. package/src/surfaces/vscode-extension.test.ts +89 -0
  91. package/src/surfaces/vscode-extension.ts +345 -0
  92. package/src/surfaces/vscode-scaffold.ts +55 -0
  93. package/src/surfaces/workflows.test.ts +3 -4
  94. package/src/surfaces/workflows.ts +49 -10
  95. package/src/toolfactory/cli.ts +1 -1
  96. package/src/toolfactory/mcp.ts +1 -1
  97. package/web/dist/assets/{index-Spapkkvh.js → index-CVm6m4s0.js} +1 -1
  98. package/web/dist/index.html +1 -1
@@ -52,12 +52,30 @@ describe("mcp-registry Dockerfile", () => {
52
52
  expect(npmBuilt.Dockerfile).not.toContain("corepack enable");
53
53
  const py = files(project({ tool: { ...project().tool, binding: "python" } }));
54
54
  expect(py.Dockerfile).toContain('ENTRYPOINT ["python","-m","hello.toolfactory.mcp"]');
55
+ expect(py.Dockerfile).toContain("COPY . .\nRUN uv sync --no-dev");
56
+ expect(py.Dockerfile).not.toContain("COPY pyproject.toml ./");
57
+ expect(py.Dockerfile).toContain('for path in README README.md README.rst LICENSE LICENSE.md LICENSE.txt NOTICE NOTICE.md NOTICE.txt; do if [ -f "$path" ]; then cp "$path" /app/runtime/; fi; done');
58
+ expect(py.Dockerfile).toContain("COPY --from=build /app/runtime/ ./");
59
+ expect(py.Dockerfile).not.toContain("COPY --from=build /app .");
55
60
  const pythonWeb = files(project({
56
61
  tool: { ...project().tool, binding: "python", surfaces: ["mcp-registry", "web"] },
57
62
  }));
58
63
  expect(pythonWeb.Dockerfile).toContain("FROM node:24-alpine AS web");
59
64
  expect(pythonWeb.Dockerfile).toContain("COPY --from=web /app/web/dist ./src/hello/web");
60
65
  });
66
+ it("ships an author-extensible Docker ignore region without excluding source metadata", () => {
67
+ const ignore = surface.plan(project()).find((file) => file.path === ".dockerignore");
68
+ if (ignore?.kind !== "region")
69
+ throw new Error("expected a Docker ignore region");
70
+ expect(ignore.template).toContain("# tf:dockerignore\n# /tf:dockerignore");
71
+ expect(ignore.regions[0]?.content).toContain(".git/");
72
+ expect(ignore.regions[0]?.content).toContain(".env.*");
73
+ expect(ignore.regions[0]?.content).toContain("!.env.example");
74
+ expect(ignore.regions[0]?.content).toContain("**/node_modules/");
75
+ expect(ignore.regions[0]?.content).toContain("**/.venv/");
76
+ expect(ignore.regions[0]?.content).not.toContain("README");
77
+ expect(ignore.regions[0]?.content).not.toContain("web/");
78
+ });
61
79
  });
62
80
  describe("mcp-registry server.json oci package", () => {
63
81
  it("adds an oci entry with a lowercased ghcr.io identifier, unconditionally", () => {
@@ -216,7 +216,7 @@ function packageJson(project, operations, kase) {
216
216
  : undefined;
217
217
  const extra = project.tool.openclaw;
218
218
  const e2e = kase ? { "test:e2e": OPENCLAW_ADDITIONS.e2eScript } : undefined;
219
- return json(compact({
219
+ return compact({
220
220
  name: projectName.openclawPackage(project.identity.name),
221
221
  version: project.identity.version ?? "0.0.0",
222
222
  description: pluginDescription(project),
@@ -239,7 +239,7 @@ function packageJson(project, operations, kase) {
239
239
  pluginInspector: expectedRegistrations(project, operations).length
240
240
  ? pluginInspector(project, operations)
241
241
  : undefined,
242
- }));
242
+ });
243
243
  }
244
244
  /**
245
245
  * `openclaw plugins build` regenerates this file; emitting it keeps `--check` green from a clean
@@ -256,7 +256,7 @@ function pluginManifest(project, operations) {
256
256
  registration.contract,
257
257
  registration.ids,
258
258
  ]);
259
- return json(compact({
259
+ return compact({
260
260
  id: project.identity.name,
261
261
  name: displayName(project.identity.name),
262
262
  description: pluginDescription(project),
@@ -268,7 +268,7 @@ function pluginManifest(project, operations) {
268
268
  tools: operations.map((operation) => operation.name),
269
269
  ...Object.fromEntries(declared),
270
270
  },
271
- }));
271
+ });
272
272
  }
273
273
  function executeBody(project, operation) {
274
274
  if (project.tool.binding === "typescript") {
@@ -819,9 +819,10 @@ export const surface = {
819
819
  const kase = e2eCase(project, operations);
820
820
  return [
821
821
  {
822
- kind: "file",
822
+ kind: "merge",
823
823
  path: `${HOST_DIR}/package.json`,
824
- content: packageJson(project, operations, kase),
824
+ format: "json",
825
+ patch: packageJson(project, operations, kase),
825
826
  },
826
827
  {
827
828
  kind: "file",
@@ -841,9 +842,10 @@ export const surface = {
841
842
  content: OPENCLAW_SCAFFOLD.vitestConfig,
842
843
  },
843
844
  {
844
- kind: "file",
845
+ kind: "merge",
845
846
  path: `${HOST_DIR}/openclaw.plugin.json`,
846
- content: pluginManifest(project, operations),
847
+ format: "json",
848
+ patch: pluginManifest(project, operations),
847
849
  },
848
850
  {
849
851
  kind: "region",
@@ -1,6 +1,10 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { describe, expect, it } from "vitest";
3
6
  import { scaffoldDrift } from "../hosts/openclaw.js";
7
+ import { apply } from "../project/apply.js";
4
8
  import { computeCoverage, renderCoverageMarkdown } from "../report/coverage.js";
5
9
  import { HOST_DIR, OPENCLAW_ADDITIONS, OPENCLAW_SCAFFOLD, surface } from "./openclaw-native.js";
6
10
  const echo = {
@@ -74,6 +78,32 @@ function emitted(target) {
74
78
  ]));
75
79
  }
76
80
  describe("openclaw-native", () => {
81
+ it("keeps hand-authored package and manifest extensions through regeneration", () => {
82
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-openclaw-"));
83
+ const target = { ...project(), root };
84
+ apply(root, surface.plan(target), "0.1.0");
85
+ const packagePath = join(root, HOST_DIR, "package.json");
86
+ const manifestPath = join(root, HOST_DIR, "openclaw.plugin.json");
87
+ writeFileSync(packagePath, JSON.stringify({
88
+ ...JSON.parse(readFileSync(packagePath, "utf8")),
89
+ scripts: { custom: "node custom.mjs" },
90
+ exports: { "./native": "./src/native.ts" },
91
+ }));
92
+ writeFileSync(manifestPath, JSON.stringify({
93
+ ...JSON.parse(readFileSync(manifestPath, "utf8")),
94
+ contracts: { customProviders: ["native"] },
95
+ nativeField: true,
96
+ }));
97
+ apply(root, surface.plan(target), "0.1.0");
98
+ expect(JSON.parse(readFileSync(packagePath, "utf8"))).toMatchObject({
99
+ scripts: { custom: "node custom.mjs" },
100
+ exports: { "./native": "./src/native.ts" },
101
+ });
102
+ expect(JSON.parse(readFileSync(manifestPath, "utf8"))).toMatchObject({
103
+ contracts: { customProviders: ["native"] },
104
+ nativeField: true,
105
+ });
106
+ });
77
107
  it("projects only the operations OpenClaw can run and pins the scaffold in one place", () => {
78
108
  const files = emitted(project());
79
109
  const manifest = JSON.parse(files[`${HOST_DIR}/openclaw.plugin.json`] ?? "{}");
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Surface "readme": the `<!-- tf:install -->` region of `README.md`, always generated (like
3
+ * `workflows` and `agents`; never in `tool.json.surfaces`). One install line per selected
4
+ * distribution surface, in the exact shape that surface's own installer accepts, so the
5
+ * humans-and-agents-facing half of distribution cannot drift from the artifacts. The prose
6
+ * around the region — what the tool is, how to use it — is the author's, forever.
7
+ *
8
+ * Every line works from a plain checkout; the ones that need a GitHub repository (`npx skills
9
+ * add`, the Agent Skill badge, the marketplace and Hermes clone URLs) appear only once the
10
+ * identity file carries one, so a tool built without GitHub still gets an accurate section.
11
+ *
12
+ * When `mcp` is selected alongside a package registry, the MCP line also carries the two
13
+ * first-party one-click badges (VS Code's `vscode.dev/redirect/mcp/install`, Cursor's
14
+ * `cursor.com/en/install-mcp`), both computed from the exact same `{command, args, env}` launch
15
+ * the plain-text line renders — a badge can never point somewhere the text line does not.
16
+ */
1
17
  import type { Project, Surface } from "../model.js";
2
18
  export declare const README_PATH = "README.md";
3
19
  export declare const INSTALL_BEGIN = "<!-- tf:install -->";
@@ -14,6 +14,8 @@
14
14
  * `cursor.com/en/install-mcp`), both computed from the exact same `{command, args, env}` launch
15
15
  * the plain-text line renders — a badge can never point somewhere the text line does not.
16
16
  */
17
+ import { nativeInstallLines } from "../distribution/native.js";
18
+ import { systemInstallLines } from "../distribution/system.js";
17
19
  import { githubSlug } from "../hosts/github.js";
18
20
  import { projectName } from "../identity/name.js";
19
21
  import { HOST_DIR as BROWSER_HOST_DIR, zipName } from "./browser-extension.js";
@@ -21,6 +23,7 @@ import { HOST_DIR as DSH_HOST_DIR, dshTarball } from "./dsh.js";
21
23
  import { pluginDir as hermesPluginDir } from "./hermes-native.js";
22
24
  import { HOST_DIR as OPENCLAW_HOST_DIR } from "./openclaw-native.js";
23
25
  import { envName, has, npmName, pypiName, requiredConfig } from "./shared.js";
26
+ import { vsixName } from "./vscode-extension.js";
24
27
  export const README_PATH = "README.md";
25
28
  export const INSTALL_BEGIN = "<!-- tf:install -->";
26
29
  export const INSTALL_END = "<!-- /tf:install -->";
@@ -70,6 +73,10 @@ function installLines(project) {
70
73
  const { identity } = project;
71
74
  const slug = githubSlug(identity.repository);
72
75
  const lines = [];
76
+ lines.push(...nativeInstallLines(project).map((line) => `- **Native package** — ${line}`));
77
+ lines.push(...systemInstallLines(project).map((line) => `- **System package** — ${line}`));
78
+ if (has(project, "vscode-extension"))
79
+ lines.push(`- **VS Code extension** — download \`${vsixName(project)}\` from the release, then \`code --install-extension ${vsixName(project)}\``);
73
80
  if (has(project, "skill") && slug) {
74
81
  lines.push(`- **Agent Skill** — \`npx skills add ${slug}\``);
75
82
  }
@@ -1,3 +1,5 @@
1
+ import { nativePackageSurfaces } from "../distribution/native.js";
2
+ import { systemPackageSurfaces } from "../distribution/system.js";
1
3
  import { surface as agentPlugins } from "./agent-plugins.js";
2
4
  import { surface as agents } from "./agents.js";
3
5
  import { surface as browserExtension } from "./browser-extension.js";
@@ -16,9 +18,12 @@ import { surface as openclawNative } from "./openclaw-native.js";
16
18
  import { surface as pypi } from "./pypi.js";
17
19
  import { surface as readme } from "./readme.js";
18
20
  import { surface as skill } from "./skill.js";
21
+ import { surface as vscodeExtension } from "./vscode-extension.js";
19
22
  import { surface as web } from "./web.js";
20
23
  import { surface as workflows } from "./workflows.js";
21
24
  const registry = {
25
+ ...Object.fromEntries(nativePackageSurfaces.map((surface) => [surface.id, surface])),
26
+ ...Object.fromEntries(systemPackageSurfaces.map((surface) => [surface.id, surface])),
22
27
  "agent-plugins": agentPlugins,
23
28
  agents,
24
29
  "browser-extension": { ...browserExtension, requires: ["mcp"] },
@@ -46,6 +51,7 @@ const registry = {
46
51
  pypi,
47
52
  readme,
48
53
  web: { ...web, requires: ["mcp"] },
54
+ "vscode-extension": { ...vscodeExtension, requires: ["mcp", "cli"] },
49
55
  workflows,
50
56
  };
51
57
  export function registerSurface(surface) {
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ import { type Project, type Surface } from "../model.js";
3
+ export declare const HOST_DIR = "hosts/vscode";
4
+ export declare const vscodeConfigSchema: z.ZodObject<{
5
+ publisher: z.ZodOptional<z.ZodString>;
6
+ marketplace: z.ZodDefault<z.ZodBoolean>;
7
+ openvsx: z.ZodDefault<z.ZodBoolean>;
8
+ }, z.core.$strip>;
9
+ export declare const VSCE_VERSION = "3.9.2";
10
+ export declare const OVSX_VERSION = "1.1.1";
11
+ export declare function extensionName(project: Project): string;
12
+ export declare function publisher(project: Project): string;
13
+ export declare function vsixName(project: Project): string;
14
+ export declare const surface: Surface;
@@ -0,0 +1,312 @@
1
+ /** A native VS Code extension. The author owns activation and all native contributions. */
2
+ import { execArgv } from "node:process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { z } from "zod";
5
+ import { githubOwner, projectName } from "../identity/name.js";
6
+ import { isPortable } from "../model.js";
7
+ import { configProperties, dataDirEnvName, envName, has, isSensitive, npmName, pypiName, rootEnvName, } from "./shared.js";
8
+ import { ESBUILD, ESLINT, GITIGNORE, IGNORE, LAUNCH, TASKS, VSCODE_SCAFFOLD, } from "./vscode-scaffold.js";
9
+ export const HOST_DIR = "hosts/vscode";
10
+ export const vscodeConfigSchema = z.object({
11
+ publisher: z
12
+ .string()
13
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/)
14
+ .optional()
15
+ .describe("Your registered Visual Studio Marketplace/Open VSX publisher; repository owner is the local packaging default."),
16
+ marketplace: z.boolean().default(true),
17
+ openvsx: z.boolean().default(true),
18
+ });
19
+ export const VSCE_VERSION = "3.9.2";
20
+ export const OVSX_VERSION = "1.1.1";
21
+ export function extensionName(project) {
22
+ return project.identity.name.replaceAll(".", "-");
23
+ }
24
+ export function publisher(project) {
25
+ return (project.tool.vscode?.publisher ??
26
+ githubOwner(project.identity.repository) ??
27
+ extensionName(project));
28
+ }
29
+ export function vsixName(project) {
30
+ return `${extensionName(project)}-${project.identity.version ?? "0.0.0"}.vsix`;
31
+ }
32
+ function operations(project) {
33
+ return project.operations.filter(isPortable);
34
+ }
35
+ function runtime(project) {
36
+ const name = extensionName(project);
37
+ const python = project.tool.binding === "python";
38
+ const packageName = python ? pypiName(project) : npmName(project);
39
+ const version = project.identity.version ?? "0.0.0";
40
+ const defaults = python
41
+ ? { command: "uvx", args: ["--from", `${packageName}==${version}`, project.identity.name] }
42
+ : { command: "npx", args: ["--yes", `${packageName}@${version}`] };
43
+ const config = Object.entries(configProperties(project)).map(([key, property]) => ({
44
+ key,
45
+ env: envName(key),
46
+ secret: isSensitive(property),
47
+ }));
48
+ return `// Generated by toolfactory. Native extension code belongs in extension.ts.
49
+ import { mkdir } from 'node:fs/promises';
50
+ import { Client } from '@modelcontextprotocol/client';
51
+ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
52
+ import * as vscode from 'vscode';
53
+
54
+ const namespace = ${JSON.stringify(name)};
55
+ const defaults = ${JSON.stringify(defaults)};
56
+ const config: Array<{key: string; env: string; secret: boolean}> = ${JSON.stringify(config)};
57
+ const operations: Array<{name: string; title: string}> = ${JSON.stringify(operations(project).map((op) => ({ name: op.name, title: op.title ?? op.name })))};
58
+
59
+ function launch(context: vscode.ExtensionContext) {
60
+ const settings = vscode.workspace.getConfiguration(namespace);
61
+ const root = vscode.Uri.joinPath(context.extensionUri, '../..').fsPath;
62
+ const development = context.extensionMode !== vscode.ExtensionMode.Production;
63
+ const local = ${python ? `{ command: 'uv', args: ['run', '--quiet', '--directory', root, 'python', '-m', '${projectName.pythonPackage(project.identity.name)}.toolfactory.cli'] }` : `{ command: 'node', args: ['--import', 'tsx', root + '/src/toolfactory/cli.ts'] }`};
64
+ const selected = development ? local : defaults;
65
+ const command = settings.get<string>('runtime.command');
66
+ return {
67
+ command: command || selected.command,
68
+ args: command ? settings.get<string[]>('runtime.args', []) : selected.args,
69
+ cwd: development ? root : undefined,
70
+ };
71
+ }
72
+
73
+ async function environment(context: vscode.ExtensionContext): Promise<Record<string, string>> {
74
+ const settings = vscode.workspace.getConfiguration(namespace);
75
+ const env: Record<string, string> = {};
76
+ for (const property of config) {
77
+ const value = (property.secret ? await context.secrets.get(property.env) : settings.get('config.' + property.key)) ?? process.env[property.env];
78
+ if (value !== undefined) env[property.env] = typeof value === 'string' ? value : JSON.stringify(value);
79
+ }
80
+ await mkdir(context.globalStorageUri.fsPath, { recursive: true });
81
+ env[${JSON.stringify(dataDirEnvName(project))}] = context.globalStorageUri.fsPath;
82
+ const folder = vscode.workspace.workspaceFolders?.find(folder => folder.uri.scheme === 'file');
83
+ if (folder) env[${JSON.stringify(rootEnvName(project))}] = folder.uri.fsPath;
84
+ return env;
85
+ }
86
+
87
+ export function registerGenerated(context: vscode.ExtensionContext): void {
88
+ const changes = new vscode.EventEmitter<void>();
89
+ context.subscriptions.push(changes,
90
+ vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration(namespace)) changes.fire(); }),
91
+ context.secrets.onDidChange(() => changes.fire()),
92
+ vscode.lm.registerMcpServerDefinitionProvider(namespace, {
93
+ onDidChangeMcpServerDefinitions: changes.event,
94
+ provideMcpServerDefinitions() {
95
+ const selected = launch(context);
96
+ const server = new vscode.McpStdioServerDefinition(
97
+ ${JSON.stringify(projectName.display(project.identity.name))}, selected.command,
98
+ [...selected.args, 'mcp'], {}, ${JSON.stringify(version)},
99
+ );
100
+ server.cwd = selected.cwd ? vscode.Uri.file(selected.cwd) : undefined;
101
+ return [server];
102
+ },
103
+ async resolveMcpServerDefinition(server) {
104
+ if (!vscode.workspace.isTrusted) throw new Error('Trust this workspace before starting the tool runtime.');
105
+ if (server instanceof vscode.McpStdioServerDefinition) server.env = { ...server.env, ...await environment(context) };
106
+ return server;
107
+ },
108
+ }),
109
+ );
110
+ ${config.some((property) => property.secret)
111
+ ? `context.subscriptions.push(vscode.commands.registerCommand(namespace + '.configureSecrets', async () => {
112
+ const chosen = await vscode.window.showQuickPick(config.filter(property => property.secret).map(property => property.env), { title: 'Configure credentials' });
113
+ if (!chosen) return;
114
+ const value = await vscode.window.showInputBox({ title: chosen, password: true, prompt: 'Stored in VS Code SecretStorage. Leave empty to remove.' });
115
+ if (value === undefined) return;
116
+ if (value) await context.secrets.store(chosen, value); else await context.secrets.delete(chosen);
117
+ }));`
118
+ : ""}
119
+ for (const operation of operations) {
120
+ context.subscriptions.push(vscode.commands.registerCommand(namespace + '.' + operation.name, async (args?: Record<string, unknown>) => {
121
+ if (!vscode.workspace.isTrusted) throw new Error('Trust this workspace before running tools.');
122
+ const interactive = args === undefined;
123
+ if (interactive) {
124
+ const input = await vscode.window.showInputBox({ title: operation.title, prompt: 'Operation arguments (JSON object)', value: '{}',
125
+ validateInput(value) { try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? undefined : 'Enter a JSON object'; } catch { return 'Enter a JSON object'; } },
126
+ });
127
+ if (input === undefined) return;
128
+ args = JSON.parse(input) as Record<string, unknown>;
129
+ }
130
+ const selected = launch(context);
131
+ const client = new Client({ name: namespace + '-vscode', version: ${JSON.stringify(version)} });
132
+ try {
133
+ await client.connect(new StdioClientTransport({ command: selected.command, args: [...selected.args, 'mcp'],
134
+ cwd: selected.cwd, env: await environment(context),
135
+ }));
136
+ const response = await client.callTool({ name: operation.name, arguments: args });
137
+ if (response.isError) throw new Error(JSON.stringify(response.content));
138
+ const result = response.structuredContent ?? response.content;
139
+ if (interactive) await vscode.window.showTextDocument(await vscode.workspace.openTextDocument({ language: 'json', content: JSON.stringify(result, null, 2) }));
140
+ return result;
141
+ } finally {
142
+ await client.close();
143
+ }
144
+ }));
145
+ }
146
+ }
147
+ `;
148
+ }
149
+ function packagePatch(project) {
150
+ const name = extensionName(project);
151
+ const properties = Object.fromEntries(Object.entries(configProperties(project))
152
+ .filter(([, property]) => !isSensitive(property))
153
+ .map(([key, property]) => {
154
+ const { "x-toolfactory": _metadata, ...schema } = property;
155
+ return [`${name}.config.${key}`, schema];
156
+ }));
157
+ const commands = operations(project).map((op) => ({
158
+ command: `${name}.${op.name}`,
159
+ title: op.title ?? op.name,
160
+ category: projectName.display(project.identity.name),
161
+ }));
162
+ if (Object.values(configProperties(project)).some(isSensitive))
163
+ commands.push({
164
+ command: `${name}.configureSecrets`,
165
+ title: "Configure credentials",
166
+ category: projectName.display(project.identity.name),
167
+ });
168
+ return {
169
+ name,
170
+ version: project.identity.version ?? "0.0.0",
171
+ displayName: projectName.display(project.identity.name),
172
+ description: project.identity.description ?? `${project.identity.name} tools for VS Code`,
173
+ publisher: publisher(project),
174
+ license: project.identity.license ?? "UNLICENSED",
175
+ ...(project.identity.repository
176
+ ? { repository: { type: "git", url: project.identity.repository } }
177
+ : {}),
178
+ engines: VSCODE_SCAFFOLD.engines,
179
+ main: "./dist/extension.js",
180
+ capabilities: { untrustedWorkspaces: { supported: false } },
181
+ contributes: {
182
+ commands,
183
+ mcpServerDefinitionProviders: [
184
+ { id: name, label: projectName.display(project.identity.name) },
185
+ ],
186
+ configuration: {
187
+ title: projectName.display(project.identity.name),
188
+ properties: {
189
+ ...properties,
190
+ [`${name}.runtime.command`]: {
191
+ type: "string",
192
+ scope: "machine",
193
+ description: "Override the tool runtime executable.",
194
+ },
195
+ [`${name}.runtime.args`]: {
196
+ type: "array",
197
+ items: { type: "string" },
198
+ scope: "machine",
199
+ description: "Arguments for runtime.command, before the mcp subcommand.",
200
+ },
201
+ },
202
+ },
203
+ },
204
+ scripts: { ...VSCODE_SCAFFOLD.scripts, vsix: "vsce package --out extension.vsix" },
205
+ dependencies: { "@modelcontextprotocol/client": "^2.0.0" },
206
+ devDependencies: { ...VSCODE_SCAFFOLD.devDependencies, "@vscode/vsce": `^${VSCE_VERSION}` },
207
+ overrides: VSCODE_SCAFFOLD.overrides,
208
+ };
209
+ }
210
+ export const surface = {
211
+ id: "vscode-extension",
212
+ plan(project) {
213
+ const registry = project.tool.binding === "python" ? "pypi" : "npm";
214
+ if (!has(project, registry))
215
+ throw new Error(`vscode-extension requires ${registry} for its installed runtime.`);
216
+ const entry = `import * as vscode from 'vscode';\nimport { registerGenerated } from './generated';\n`;
217
+ const probe = operations(project).find((op) => project.tool.tests.examples[op.name]);
218
+ return [
219
+ {
220
+ kind: "merge",
221
+ path: `${HOST_DIR}/package.json`,
222
+ format: "json",
223
+ patch: packagePatch(project),
224
+ keyedArrays: {
225
+ "contributes.commands": "command",
226
+ "contributes.mcpServerDefinitionProviders": "id",
227
+ },
228
+ },
229
+ { kind: "file", path: `${HOST_DIR}/src/generated.ts`, content: runtime(project) },
230
+ {
231
+ kind: "region",
232
+ path: `${HOST_DIR}/src/extension.ts`,
233
+ regions: [{ begin: "// tf:imports", end: "// /tf:imports", content: `\n${entry}` }],
234
+ template: `// tf:imports\n// /tf:imports\n\n// This is your extension entry. Use the complete VS Code API here.\nexport function activate(context: vscode.ExtensionContext) {\n registerGenerated(context);\n}\n\nexport function deactivate() {}\n`,
235
+ },
236
+ {
237
+ kind: "merge",
238
+ path: `${HOST_DIR}/tsconfig.json`,
239
+ format: "json",
240
+ patch: {
241
+ compilerOptions: {
242
+ module: "Node16",
243
+ target: "ES2022",
244
+ lib: ["ES2022"],
245
+ types: ["node", "mocha"],
246
+ sourceMap: true,
247
+ rootDir: "src",
248
+ strict: true,
249
+ },
250
+ },
251
+ },
252
+ ...[
253
+ ["esbuild.js", ESBUILD],
254
+ ["eslint.config.mjs", ESLINT],
255
+ [".vscode/launch.json", LAUNCH],
256
+ [".vscode/tasks.json", TASKS],
257
+ [".vscodeignore", IGNORE],
258
+ [".gitignore", GITIGNORE],
259
+ ].map(([path, content]) => ({
260
+ kind: "region",
261
+ path: `${HOST_DIR}/${path}`,
262
+ regions: [
263
+ {
264
+ begin: path?.endsWith("ignore") ? "# tf:scaffold" : "// tf:scaffold",
265
+ end: path?.endsWith("ignore") ? "# /tf:scaffold" : "// /tf:scaffold",
266
+ content: "\n",
267
+ },
268
+ ],
269
+ template: `${path?.endsWith("ignore") ? "# tf:scaffold\n# /tf:scaffold" : "// tf:scaffold\n// /tf:scaffold"}\n${content}`,
270
+ })),
271
+ {
272
+ kind: "file",
273
+ path: `${HOST_DIR}/.vscode-test.mjs`,
274
+ content: `import { defineConfig } from '@vscode/test-cli';\nexport default defineConfig({ files: 'out/test/**/*.test.js', launchArgs: ['--disable-workspace-trust', '--skip-welcome', '--skip-release-notes', '--no-sandbox'] });\n`,
275
+ },
276
+ {
277
+ kind: "file",
278
+ path: `${HOST_DIR}/src/test/generated.test.ts`,
279
+ content: `import * as assert from 'node:assert/strict';\nimport * as vscode from 'vscode';\nsuite('Generated extension', () => {\n test('activates and registers the operations', async () => {\n const extension = vscode.extensions.getExtension(${JSON.stringify(`${publisher(project)}.${extensionName(project)}`)});\n assert.ok(extension);\n await extension.activate();\n const commands = await vscode.commands.getCommands(true);\n for (const command of ${JSON.stringify(operations(project).map((op) => `${extensionName(project)}.${op.name}`))}) assert.ok(commands.includes(command), command);\n ${probe ? `await vscode.commands.executeCommand(${JSON.stringify(`${extensionName(project)}.${probe.name}`)}, ${JSON.stringify(project.tool.tests.examples[probe.name])});` : ""}\n });\n});\n`,
280
+ },
281
+ {
282
+ kind: "file",
283
+ path: `${HOST_DIR}/README.md`,
284
+ content: `# ${projectName.display(project.identity.name)} for VS Code\n\n${project.identity.description ?? ""}\n\nInstall the VSIX with \`code --install-extension ${vsixName(project)}\`. The extension provides an MCP server and commands for each portable operation. ${registry === "npm" ? "Node.js/npm" : "uv"} must be on PATH; installed extensions use the matching published ${registry} version.\n\nDevelop with \`npm install\`, then open this directory in VS Code and press F5. Development and tests use the checkout's kernel. \`npm test\` runs a real Extension Development Host; \`npm run vsix\` creates the installable package.\n\nEdit \`src/extension.ts\` outside its import markers and add native files freely. Use all VS Code APIs: language-model tools, chat participants, views, webviews, commands, editors and language services. Add native contribution points and additional commands directly to \`package.json\`; generated command/provider entries are maintained by identity, preserving your entries. Build scripts, debugger settings and ignore rules are editable scaffold files. Use \`adopt\` only when replacing a generated integration itself.\n\nConfiguration is in VS Code Settings; credentials use SecretStorage through the Configure credentials command when declared. The runtime command/arguments can be overridden in machine settings.\n\nPublishers must be registered separately with Visual Studio Marketplace and Open VSX. Releases attach a VSIX even when no publishing credentials are configured.\n`,
285
+ },
286
+ ];
287
+ },
288
+ validate(project) {
289
+ const cwd = `${project.root}/${HOST_DIR}`;
290
+ const drift = fileURLToPath(new URL("../hosts/vscode.js", import.meta.url));
291
+ return [
292
+ {
293
+ label: "VS Code upstream scaffold",
294
+ command: "node",
295
+ args: [...execArgv, drift, project.root],
296
+ },
297
+ { label: "VS Code dependencies", command: "npm", args: ["install"], cwd },
298
+ { label: "VS Code VSIX", command: "npm", args: ["run", "vsix"], cwd },
299
+ {
300
+ label: "VS Code Extension Development Host",
301
+ command: process.platform === "linux" ? "xvfb-run" : "npm",
302
+ args: process.platform === "linux" ? ["-a", "npm", "test"] : ["test"],
303
+ cwd,
304
+ },
305
+ ];
306
+ },
307
+ verdict(operation) {
308
+ return isPortable(operation)
309
+ ? { kind: "bridged", reason: "bridged:vscode-mcp" }
310
+ : { kind: "excluded", reason: "excluded:implement-in-hosts" };
311
+ },
312
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, expect, it } from "vitest";
5
+ import { ToolConfigSchema } from "../model.js";
6
+ import { apply, check } from "../project/apply.js";
7
+ import { packageSteps, registries } from "../project/gate.js";
8
+ import { surface } from "./vscode-extension.js";
9
+ const roots = [];
10
+ afterEach(() => {
11
+ for (const root of roots.splice(0))
12
+ rmSync(root, { recursive: true, force: true });
13
+ });
14
+ function fixture() {
15
+ const root = mkdtempSync(join(tmpdir(), "tf-vscode-"));
16
+ roots.push(root);
17
+ return {
18
+ root,
19
+ tool: ToolConfigSchema.parse({
20
+ schemaVersion: 1,
21
+ identity: "plugin.json",
22
+ binding: "typescript",
23
+ surfaces: ["mcp", "cli", "npm", "vscode-extension"],
24
+ vscode: { publisher: "octo" },
25
+ }),
26
+ identity: { name: "hello.tool", version: "1.0.0", repository: "https://github.com/octo/hello" },
27
+ identityExtra: {},
28
+ operations: [
29
+ { name: "echo", inputSchema: { type: "object" }, requires: [] },
30
+ { name: "native", inputSchema: { type: "object" }, requires: ["model"] },
31
+ ],
32
+ toolfactoryVersion: "0.1.2",
33
+ };
34
+ }
35
+ it("keeps native contributions, activation, build settings and command details across operation changes", () => {
36
+ const project = fixture();
37
+ apply(project.root, surface.plan(project), "0.1.2");
38
+ const path = join(project.root, "hosts/vscode/package.json");
39
+ const pkg = JSON.parse(readFileSync(path, "utf8"));
40
+ pkg.contributes.commands[0].icon = "$(beaker)";
41
+ pkg.contributes.commands.push({ command: "hello.tool.custom", title: "Custom native command" });
42
+ pkg.contributes.views = { explorer: [{ id: "native-view", name: "Native view" }] };
43
+ pkg.scripts.custom = "node custom.js";
44
+ writeFileSync(path, JSON.stringify(pkg));
45
+ const entry = join(project.root, "hosts/vscode/src/extension.ts");
46
+ writeFileSync(entry, `${readFileSync(entry, "utf8")}\nexport const authored = true;\n`);
47
+ expect(check(project.root, surface.plan(project), "0.1.2")).toEqual([]);
48
+ const echo = project.operations[0];
49
+ if (!echo)
50
+ throw new Error("fixture must contain echo");
51
+ echo.title = "New title";
52
+ apply(project.root, surface.plan(project), "0.1.2");
53
+ const rebuilt = JSON.parse(readFileSync(path, "utf8"));
54
+ expect(rebuilt.contributes.commands).toContainEqual({
55
+ command: "hello-tool.echo",
56
+ title: "New title",
57
+ category: "Hello Tool",
58
+ icon: "$(beaker)",
59
+ });
60
+ expect(rebuilt.contributes.commands).toContainEqual({
61
+ command: "hello.tool.custom",
62
+ title: "Custom native command",
63
+ });
64
+ expect(rebuilt.contributes.commands).not.toContainEqual(expect.objectContaining({ command: "hello-tool.native" }));
65
+ expect(rebuilt.contributes.views).toEqual(pkg.contributes.views);
66
+ expect(rebuilt.scripts.custom).toBe("node custom.js");
67
+ expect(readFileSync(entry, "utf8")).toContain("export const authored = true;");
68
+ expect(check(project.root, surface.plan(project), "0.1.2")).toEqual([]);
69
+ });
70
+ it("packages a VSIX independently of registry credentials and permits each registry to be deselected", () => {
71
+ const project = fixture();
72
+ expect(packageSteps(project).some((step) => step.run.includes("hello-tool-1.0.0.vsix"))).toBe(true);
73
+ expect(registries(project).map((row) => row.id)).toEqual(expect.arrayContaining(["vscode-marketplace", "open-vsx"]));
74
+ project.tool.vscode = { publisher: "octo", marketplace: false, openvsx: true };
75
+ expect(registries(project).map((row) => row.id)).not.toContain("vscode-marketplace");
76
+ project.tool.binding = "python";
77
+ expect(() => surface.plan(project)).toThrow("requires pypi");
78
+ project.tool.surfaces = ["mcp", "cli", "pypi", "vscode-extension"];
79
+ const runtime = surface.plan(project).find((file) => file.path.endsWith("/generated.ts"));
80
+ expect(runtime?.kind === "file" && runtime.content).toContain('"command":"uvx"');
81
+ });