@gmickel/gno 1.12.4 → 1.13.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 (61) hide show
  1. package/README.md +57 -30
  2. package/assets/skill/SKILL.md +5 -0
  3. package/assets/skill/cli-reference.md +16 -6
  4. package/assets/skill/mcp-reference.md +22 -3
  5. package/package.json +2 -1
  6. package/src/app/constants.ts +43 -10
  7. package/src/app/index-name.ts +127 -0
  8. package/src/cli/commands/doctor-activation.ts +151 -0
  9. package/src/cli/commands/doctor.ts +41 -16
  10. package/src/cli/commands/get.ts +18 -0
  11. package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
  12. package/src/cli/commands/mcp/config-discovery.ts +42 -0
  13. package/src/cli/commands/mcp/config-editors.ts +432 -0
  14. package/src/cli/commands/mcp/config.ts +63 -160
  15. package/src/cli/commands/mcp/install.ts +75 -37
  16. package/src/cli/commands/mcp/paths.ts +141 -136
  17. package/src/cli/commands/mcp/server-entry.ts +66 -0
  18. package/src/cli/commands/mcp/status.ts +189 -57
  19. package/src/cli/commands/mcp/target-display.ts +30 -0
  20. package/src/cli/commands/mcp/uninstall.ts +29 -31
  21. package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
  22. package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
  23. package/src/cli/commands/multi-get.ts +31 -6
  24. package/src/cli/commands/status.ts +107 -11
  25. package/src/cli/program.ts +66 -20
  26. package/src/core/activation-connector-health.ts +19 -0
  27. package/src/core/activation-probe-plan.ts +321 -0
  28. package/src/core/activation-probe.ts +138 -0
  29. package/src/core/activation-receipt-store.ts +39 -0
  30. package/src/core/activation-status.ts +513 -0
  31. package/src/core/activation-verifier.ts +416 -0
  32. package/src/core/connector-environment.ts +68 -0
  33. package/src/core/connector-policy.ts +233 -0
  34. package/src/core/connector-verification-target.ts +150 -0
  35. package/src/core/connector-verifier.ts +497 -0
  36. package/src/core/indexed-reference.ts +33 -8
  37. package/src/core/runtime-entrypoint.ts +24 -0
  38. package/src/mcp/activation-verification-mode.ts +4 -0
  39. package/src/mcp/server.ts +9 -2
  40. package/src/sdk/client.ts +7 -0
  41. package/src/sdk/types.ts +1 -0
  42. package/src/serve/activation-health.ts +91 -0
  43. package/src/serve/background-runtime.ts +11 -1
  44. package/src/serve/connectors.ts +164 -19
  45. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  46. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  47. package/src/serve/public/components/HealthCenter.tsx +8 -2
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/pages/Connectors.tsx +216 -55
  50. package/src/serve/public/pages/Dashboard.tsx +1 -0
  51. package/src/serve/routes/api.ts +152 -8
  52. package/src/serve/server.ts +44 -9
  53. package/src/serve/status-model.ts +4 -0
  54. package/src/serve/status.ts +79 -35
  55. package/src/store/activation-receipts.ts +390 -0
  56. package/src/store/index.ts +8 -0
  57. package/src/store/migrations/012-activation-receipts.ts +38 -0
  58. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  59. package/src/store/migrations/index.ts +4 -0
  60. package/src/store/sqlite/adapter.ts +313 -53
  61. package/src/store/types.ts +118 -0
@@ -5,9 +5,17 @@
5
5
  * @module src/cli/commands/mcp/paths
6
6
  */
7
7
 
8
- import { existsSync } from "node:fs";
9
8
  import { homedir, platform } from "node:os";
10
- import { join } from "node:path";
9
+ import { join, resolve } from "node:path";
10
+
11
+ import type { ConnectorWorkspaceEnvironment } from "../../../core/connector-environment";
12
+
13
+ import { resolveDirs } from "../../../app/constants";
14
+ import { assertValidIndexName } from "../../../app/index-name";
15
+ import { getCurrentGnoEntrypoint } from "../../../core/runtime-entrypoint";
16
+ import { getTargetDisplayName } from "./target-display.js";
17
+
18
+ export { getTargetDisplayName } from "./target-display.js";
11
19
 
12
20
  // ─────────────────────────────────────────────────────────────────────────────
13
21
  // Types
@@ -40,7 +48,8 @@ export type McpConfigFormat =
40
48
  | "context_servers"
41
49
  | "mcp"
42
50
  | "amp_mcp"
43
- | "yaml_standard";
51
+ | "yaml_standard"
52
+ | "codex_toml";
44
53
 
45
54
  export interface McpConfigPaths {
46
55
  /** Config file path */
@@ -49,11 +58,22 @@ export interface McpConfigPaths {
49
58
  supportsProjectScope: boolean;
50
59
  /** Config format for this target */
51
60
  configFormat: McpConfigFormat;
61
+ /** Supported alternate filenames, checked without creating duplicates. */
62
+ alternativeConfigPaths?: string[];
52
63
  }
53
64
 
54
65
  export interface McpServerEntry {
55
66
  command: string;
56
67
  args: string[];
68
+ env?: ConnectorWorkspaceEnvironment;
69
+ }
70
+
71
+ interface McpServerEntryOptions {
72
+ enableWrite?: boolean;
73
+ indexName?: string;
74
+ configPath?: string;
75
+ dataDir?: string;
76
+ cacheDir?: string;
57
77
  }
58
78
 
59
79
  export interface McpPathOptions {
@@ -63,6 +83,10 @@ export interface McpPathOptions {
63
83
  cwd?: string;
64
84
  /** Override home dir (testing) */
65
85
  homeDir?: string;
86
+ /** Override runtime platform (testing) */
87
+ platform?: NodeJS.Platform;
88
+ /** Override process environment used for platform config directories (testing) */
89
+ env?: Readonly<Record<string, string | undefined>>;
66
90
  }
67
91
 
68
92
  // ─────────────────────────────────────────────────────────────────────────────
@@ -85,18 +109,43 @@ export const MCP_TARGETS: McpTarget[] = [
85
109
  "librechat",
86
110
  ];
87
111
 
88
- /** Targets that support project scope */
89
- export const TARGETS_WITH_PROJECT_SCOPE: McpTarget[] = [
90
- "claude-code",
91
- "codex",
92
- "cursor",
93
- "opencode",
94
- "librechat",
95
- ];
112
+ /** Canonical target scope support. LibreChat has no user-global config. */
113
+ export const MCP_TARGET_SCOPES: Readonly<
114
+ Record<McpTarget, readonly McpScope[]>
115
+ > = {
116
+ "claude-desktop": ["user"],
117
+ "claude-code": ["user", "project"],
118
+ codex: ["user", "project"],
119
+ cursor: ["user", "project"],
120
+ zed: ["user"],
121
+ windsurf: ["user"],
122
+ opencode: ["user", "project"],
123
+ amp: ["user"],
124
+ lmstudio: ["user"],
125
+ librechat: ["project"],
126
+ };
127
+
128
+ /** Targets that support project scope. */
129
+ export const TARGETS_WITH_PROJECT_SCOPE: McpTarget[] = MCP_TARGETS.filter(
130
+ (target) => MCP_TARGET_SCOPES[target].includes("project")
131
+ );
132
+
133
+ /** Return the supported scopes in canonical display/operation order. */
134
+ export function getTargetScopes(target: McpTarget): readonly McpScope[] {
135
+ return MCP_TARGET_SCOPES[target];
136
+ }
137
+
138
+ /** Default to user scope when available, otherwise the sole supported scope. */
139
+ export function getDefaultTargetScope(target: McpTarget): McpScope {
140
+ const scopes = getTargetScopes(target);
141
+ return scopes.includes("user") ? "user" : (scopes[0] ?? "user");
142
+ }
96
143
 
97
144
  /** Get config format for a target */
98
145
  export function getTargetConfigFormat(target: McpTarget): McpConfigFormat {
99
146
  switch (target) {
147
+ case "codex":
148
+ return "codex_toml";
100
149
  case "zed":
101
150
  return "context_servers";
102
151
  case "opencode":
@@ -110,9 +159,6 @@ export function getTargetConfigFormat(target: McpTarget): McpConfigFormat {
110
159
  }
111
160
  }
112
161
 
113
- /** Regex to extract entry script path from command path */
114
- const COMMANDS_PATH_PATTERN = /\/commands\/.*$/;
115
-
116
162
  // ─────────────────────────────────────────────────────────────────────────────
117
163
  // Config Path Resolution
118
164
  // ─────────────────────────────────────────────────────────────────────────────
@@ -156,9 +202,9 @@ function resolveClaudeCodePath(
156
202
  */
157
203
  function resolveCodexPath(scope: McpScope, home: string, cwd: string): string {
158
204
  if (scope === "user") {
159
- return join(home, ".codex.json");
205
+ return join(home, ".codex/config.toml");
160
206
  }
161
- return join(cwd, ".codex/.mcp.json");
207
+ return join(cwd, ".codex/config.toml");
162
208
  }
163
209
 
164
210
  /**
@@ -175,17 +221,18 @@ function resolveCursorPath(scope: McpScope, home: string, cwd: string): string {
175
221
  return join(cwd, ".cursor/mcp.json");
176
222
  }
177
223
 
178
- /**
179
- * Resolve Zed config path (macOS/Linux only, no project scope).
180
- */
181
- function resolveZedPath(home: string): string {
182
- const plat = platform();
224
+ /** Resolve the user-level Zed config path. */
225
+ function resolveZedPath(
226
+ home: string,
227
+ plat: NodeJS.Platform,
228
+ env: Readonly<Record<string, string | undefined>>
229
+ ): string {
183
230
  if (plat === "win32") {
184
- // Zed not available on Windows, but provide path anyway
185
- return join(home, ".config/zed/settings.json");
231
+ const appData = env.APPDATA?.trim() || join(home, "AppData", "Roaming");
232
+ return join(appData, "Zed", "settings.json");
186
233
  }
187
234
  // macOS and Linux use XDG or fallback
188
- const xdgConfig = process.env.XDG_CONFIG_HOME;
235
+ const xdgConfig = env.XDG_CONFIG_HOME;
189
236
  if (xdgConfig) {
190
237
  return join(xdgConfig, "zed/settings.json");
191
238
  }
@@ -214,9 +261,9 @@ function resolveOpenCodePath(
214
261
  if (scope === "user") {
215
262
  const plat = platform();
216
263
  if (plat === "win32") {
217
- return join(home, ".config", "opencode", "config.json");
264
+ return join(home, ".config", "opencode", "opencode.json");
218
265
  }
219
- return join(home, ".config/opencode/config.json");
266
+ return join(home, ".config/opencode/opencode.json");
220
267
  }
221
268
  // Project scope: opencode.json in project root
222
269
  return join(cwd, "opencode.json");
@@ -256,12 +303,15 @@ function resolveLibreChatPath(cwd: string): string {
256
303
  * Resolve MCP config path for a given target and scope.
257
304
  */
258
305
  export function resolveMcpConfigPath(opts: McpPathOptions): McpConfigPaths {
259
- const {
260
- target,
261
- scope = "user",
262
- cwd = process.cwd(),
263
- homeDir = homedir(),
264
- } = opts;
306
+ const { target, cwd = process.cwd(), homeDir = homedir() } = opts;
307
+ const runtimePlatform = opts.platform ?? platform();
308
+ const runtimeEnv = opts.env ?? process.env;
309
+ const scope = opts.scope ?? getDefaultTargetScope(target);
310
+ if (!getTargetScopes(target).includes(scope)) {
311
+ throw new Error(
312
+ `${getTargetDisplayName(target)} does not support ${scope} scope.`
313
+ );
314
+ }
265
315
 
266
316
  const configFormat = getTargetConfigFormat(target);
267
317
  const supportsProjectScope = TARGETS_WITH_PROJECT_SCOPE.includes(target);
@@ -293,7 +343,7 @@ export function resolveMcpConfigPath(opts: McpPathOptions): McpConfigPaths {
293
343
  };
294
344
  case "zed":
295
345
  return {
296
- configPath: resolveZedPath(homeDir),
346
+ configPath: resolveZedPath(homeDir, runtimePlatform, runtimeEnv),
297
347
  supportsProjectScope,
298
348
  configFormat,
299
349
  };
@@ -303,18 +353,24 @@ export function resolveMcpConfigPath(opts: McpPathOptions): McpConfigPaths {
303
353
  supportsProjectScope,
304
354
  configFormat,
305
355
  };
306
- case "opencode":
356
+ case "opencode": {
357
+ const configPath = resolveOpenCodePath(scope, homeDir, cwd);
307
358
  return {
308
- configPath: resolveOpenCodePath(scope, homeDir, cwd),
359
+ configPath,
360
+ alternativeConfigPaths: [configPath.replace(/\.json$/u, ".jsonc")],
309
361
  supportsProjectScope,
310
362
  configFormat,
311
363
  };
312
- case "amp":
364
+ }
365
+ case "amp": {
366
+ const configPath = resolveAmpPath(homeDir);
313
367
  return {
314
- configPath: resolveAmpPath(homeDir),
368
+ configPath,
369
+ alternativeConfigPaths: [configPath.replace(/\.json$/u, ".jsonc")],
315
370
  supportsProjectScope,
316
371
  configFormat,
317
372
  };
373
+ }
318
374
  case "lmstudio":
319
375
  return {
320
376
  configPath: resolveLmStudioPath(homeDir),
@@ -340,7 +396,7 @@ export function resolveMcpConfigPath(opts: McpPathOptions): McpConfigPaths {
340
396
  export function resolveAllMcpPaths(
341
397
  scope: McpScope | "all" = "all",
342
398
  target: McpTarget | "all" = "all",
343
- overrides?: { cwd?: string; homeDir?: string }
399
+ overrides?: Pick<McpPathOptions, "cwd" | "env" | "homeDir" | "platform">
344
400
  ): Array<{ target: McpTarget; scope: McpScope; paths: McpConfigPaths }> {
345
401
  const targets: McpTarget[] = target === "all" ? MCP_TARGETS : [target];
346
402
  const results: Array<{
@@ -350,28 +406,29 @@ export function resolveAllMcpPaths(
350
406
  }> = [];
351
407
 
352
408
  for (const t of targets) {
353
- const supportsProject = TARGETS_WITH_PROJECT_SCOPE.includes(t);
354
-
355
- if (supportsProject) {
356
- // Targets that support both scopes
357
- const scopes: McpScope[] =
358
- scope === "all" ? ["user", "project"] : [scope];
359
- for (const s of scopes) {
360
- results.push({
361
- target: t,
362
- scope: s,
363
- paths: resolveMcpConfigPath({ target: t, scope: s, ...overrides }),
364
- });
365
- }
366
- } else {
367
- // User scope only - skip if filtering by project
368
- if (scope === "project") {
409
+ const supportedScopes = getTargetScopes(t);
410
+ const scopes =
411
+ scope === "all"
412
+ ? supportedScopes
413
+ : supportedScopes.includes(scope)
414
+ ? [scope]
415
+ : [];
416
+ const seenConfigPaths = new Set<string>();
417
+ for (const targetScope of scopes) {
418
+ const paths = resolveMcpConfigPath({
419
+ target: t,
420
+ scope: targetScope,
421
+ ...overrides,
422
+ });
423
+ const configIdentity = resolve(paths.configPath);
424
+ if (seenConfigPaths.has(configIdentity)) {
369
425
  continue;
370
426
  }
427
+ seenConfigPaths.add(configIdentity);
371
428
  results.push({
372
429
  target: t,
373
- scope: "user",
374
- paths: resolveMcpConfigPath({ target: t, scope: "user", ...overrides }),
430
+ scope: targetScope,
431
+ paths,
375
432
  });
376
433
  }
377
434
  }
@@ -394,93 +451,41 @@ export function findBunPath(): string {
394
451
  }
395
452
 
396
453
  /**
397
- * Detect how gno should be invoked and return the MCP server entry.
398
- * Uses absolute paths because Claude Desktop has a limited PATH.
399
- * Cross-platform: avoids shelling out to `which`.
454
+ * Return an MCP server entry bound to the GNO runtime installing it.
455
+ * Uses absolute paths because desktop clients have a limited PATH.
400
456
  */
401
457
  export function buildMcpServerEntry(
402
- options: {
403
- enableWrite?: boolean;
404
- } = {}
458
+ options: McpServerEntryOptions = {}
405
459
  ): McpServerEntry {
406
- const bunPath = findBunPath();
407
- const home = homedir();
408
- const isWindows = platform() === "win32";
409
-
410
- // 1. Check if running from source (dev mode)
411
- const scriptPath = process.argv[1];
412
- if (
413
- scriptPath?.includes("/gno/src/cli/") ||
414
- scriptPath?.includes("\\gno\\src\\cli\\")
415
- ) {
416
- // Dev mode: run the entry script directly with bun
417
- const entryScript = scriptPath.replace(COMMANDS_PATH_PATTERN, "/index.ts");
418
- const args = ["run", entryScript, "mcp"];
419
- if (options.enableWrite) {
420
- args.push("--enable-write");
421
- }
422
- return { command: bunPath, args };
460
+ if (options.indexName !== undefined) {
461
+ assertValidIndexName(options.indexName);
423
462
  }
463
+ const bunPath = findBunPath();
464
+ const args = ["run", getCurrentGnoEntrypoint()];
465
+ appendMcpArguments(args, options);
466
+ const dirs = resolveDirs();
467
+ return {
468
+ command: bunPath,
469
+ args,
470
+ env: {
471
+ GNO_DATA_DIR: resolve(options.dataDir ?? dirs.data),
472
+ GNO_CACHE_DIR: resolve(options.cacheDir ?? dirs.cache),
473
+ },
474
+ };
475
+ }
424
476
 
425
- // 2. Check common gno install locations (cross-platform)
426
- const gnoCandidates = isWindows
427
- ? [
428
- join(home, ".bun\\bin\\gno.exe"),
429
- join(home, "AppData\\Roaming\\npm\\gno.cmd"),
430
- ]
431
- : [
432
- join(home, ".bun/bin/gno"),
433
- "/usr/local/bin/gno",
434
- "/opt/homebrew/bin/gno",
435
- ];
436
-
437
- for (const gnoPath of gnoCandidates) {
438
- if (existsSync(gnoPath)) {
439
- const args = [gnoPath, "mcp"];
440
- if (options.enableWrite) {
441
- args.push("--enable-write");
442
- }
443
- return { command: bunPath, args };
444
- }
477
+ function appendMcpArguments(
478
+ args: string[],
479
+ options: McpServerEntryOptions
480
+ ): void {
481
+ if (options.indexName) {
482
+ args.push("--index", options.indexName);
445
483
  }
446
-
447
- // 3. Fallback to bunx (works if gno is published to npm)
448
- // Note: This may trigger network access on first run
449
- const args = ["x", "@gmickel/gno", "mcp"];
484
+ if (options.configPath) {
485
+ args.push("--config", resolve(options.configPath));
486
+ }
487
+ args.push("mcp");
450
488
  if (options.enableWrite) {
451
489
  args.push("--enable-write");
452
490
  }
453
- return { command: bunPath, args };
454
- }
455
-
456
- /**
457
- * Get display name for a target.
458
- */
459
- export function getTargetDisplayName(target: McpTarget): string {
460
- switch (target) {
461
- case "claude-desktop":
462
- return "Claude Desktop";
463
- case "claude-code":
464
- return "Claude Code";
465
- case "codex":
466
- return "Codex";
467
- case "cursor":
468
- return "Cursor";
469
- case "zed":
470
- return "Zed";
471
- case "windsurf":
472
- return "Windsurf";
473
- case "opencode":
474
- return "OpenCode";
475
- case "amp":
476
- return "Amp";
477
- case "lmstudio":
478
- return "LM Studio";
479
- case "librechat":
480
- return "LibreChat";
481
- default: {
482
- const _exhaustive: never = target;
483
- throw new Error(`Unknown target: ${String(_exhaustive)}`);
484
- }
485
- }
486
491
  }
@@ -0,0 +1,66 @@
1
+ /** Structural validation at the MCP client-config write boundary. */
2
+
3
+ // node:path has no Bun equivalent for portable absolute-path validation.
4
+ import { isAbsolute } from "node:path";
5
+
6
+ import type { McpServerEntry } from "./paths.js";
7
+
8
+ import { normalizeConnectorWorkspaceEnvironment } from "../../../core/connector-environment.js";
9
+ import { CliError } from "../../errors.js";
10
+
11
+ function hasControlCharacter(value: string): boolean {
12
+ for (const character of value) {
13
+ const codePoint = character.codePointAt(0) ?? 0;
14
+ if (codePoint <= 31 || codePoint === 127) {
15
+ return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
20
+
21
+ export function normalizeMcpServerEntryForInstall(
22
+ input: unknown
23
+ ): McpServerEntry {
24
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
25
+ throw new CliError("VALIDATION", "MCP server entry must be an object.");
26
+ }
27
+ const prototype = Object.getPrototypeOf(input);
28
+ if (prototype !== Object.prototype && prototype !== null) {
29
+ throw new CliError(
30
+ "VALIDATION",
31
+ "MCP server entry must be a plain object."
32
+ );
33
+ }
34
+ const record = input as Record<string, unknown>;
35
+ if (
36
+ Object.keys(record).some(
37
+ (key) => key !== "command" && key !== "args" && key !== "env"
38
+ ) ||
39
+ typeof record.command !== "string" ||
40
+ !record.command ||
41
+ hasControlCharacter(record.command) ||
42
+ !isAbsolute(record.command) ||
43
+ !Array.isArray(record.args) ||
44
+ record.args.length === 0 ||
45
+ !record.args.every(
46
+ (argument) =>
47
+ typeof argument === "string" &&
48
+ argument.length > 0 &&
49
+ !hasControlCharacter(argument)
50
+ )
51
+ ) {
52
+ throw new CliError(
53
+ "VALIDATION",
54
+ "MCP server entry requires an absolute command and non-empty string arguments."
55
+ );
56
+ }
57
+ const env = normalizeConnectorWorkspaceEnvironment(record.env);
58
+ if (env === null) {
59
+ throw new CliError("VALIDATION", "Invalid MCP workspace environment.");
60
+ }
61
+ return {
62
+ command: record.command,
63
+ args: record.args,
64
+ ...(Object.keys(env).length > 0 ? { env } : {}),
65
+ };
66
+ }