@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
@@ -4,22 +4,31 @@
4
4
  * @module src/cli/commands/mcp/status
5
5
  */
6
6
 
7
+ import type { ConnectorWorkspaceEnvironment } from "../../../core/connector-environment";
8
+ import type { McpConnectorVerificationTarget } from "../../../core/connector-verifier";
9
+ import type { StandardMcpEntry } from "./config.js";
10
+
11
+ import { normalizeConnectorWorkspaceEnvironment } from "../../../core/connector-environment";
12
+ import { CliError } from "../../errors.js";
7
13
  import { getGlobals } from "../../program.js";
8
14
  import {
9
- type AnyMcpConfig,
10
- getServerEntry,
11
- isYamlFormat,
12
- type OpenCodeMcpEntry,
13
- type StandardMcpEntry,
14
- } from "./config.js";
15
+ configPathEntryExists,
16
+ resolveMcpConfigLocation,
17
+ } from "./config-discovery.js";
18
+ import {
19
+ getJsoncServerEntry,
20
+ getTomlServerEntry,
21
+ getYamlServerEntry,
22
+ } from "./config-editors.js";
23
+ import { getServersKey } from "./config.js";
15
24
  import {
25
+ getTargetScopes,
16
26
  getTargetDisplayName,
17
- MCP_SERVER_NAME,
18
27
  MCP_TARGETS,
28
+ type McpConfigFormat,
19
29
  type McpScope,
20
30
  type McpTarget,
21
31
  resolveMcpConfigPath,
22
- TARGETS_WITH_PROJECT_SCOPE,
23
32
  } from "./paths.js";
24
33
 
25
34
  // ─────────────────────────────────────────────────────────────────────────────
@@ -42,10 +51,40 @@ export interface McpTargetStatus {
42
51
  scope: McpScope;
43
52
  configPath: string;
44
53
  configured: boolean;
45
- serverEntry?: { command: string; args: string[] };
54
+ serverEntry?: {
55
+ command: string;
56
+ args: string[];
57
+ env?: ConnectorWorkspaceEnvironment;
58
+ };
46
59
  error?: string;
47
60
  }
48
61
 
62
+ /** Verification-only identity; deliberately omitted from status JSON output. */
63
+ const configIdentityByStatus = new WeakMap<McpTargetStatus, string>();
64
+
65
+ /** Project a parsed target status into the read-only activation verifier seam. */
66
+ export function toMcpConnectorVerificationTarget(
67
+ id: string,
68
+ status: McpTargetStatus
69
+ ): McpConnectorVerificationTarget {
70
+ const serverEntry = normalizeEntry(status.serverEntry, "standard");
71
+ const configured = status.configured && serverEntry !== null;
72
+ const configIdentity = configIdentityByStatus.get(status);
73
+ return {
74
+ kind: "mcp",
75
+ id,
76
+ target: status.target,
77
+ scope: status.scope,
78
+ configPath: status.configPath,
79
+ configured,
80
+ ...(serverEntry ? { serverEntry } : {}),
81
+ ...(configIdentity ? { configIdentity } : {}),
82
+ ...(status.error || (status.configured && !serverEntry)
83
+ ? { configError: true }
84
+ : {}),
85
+ };
86
+ }
87
+
49
88
  interface StatusResult {
50
89
  targets: McpTargetStatus[];
51
90
  summary: {
@@ -62,14 +101,71 @@ interface StatusResult {
62
101
  * Normalize entry to standard format for display.
63
102
  */
64
103
  function normalizeEntry(
65
- entry: StandardMcpEntry | OpenCodeMcpEntry
66
- ): StandardMcpEntry {
67
- if ("type" in entry && entry.type === "local") {
104
+ entry: unknown,
105
+ configFormat: McpConfigFormat
106
+ ): StandardMcpEntry | null {
107
+ if (!entry || typeof entry !== "object") {
108
+ return null;
109
+ }
110
+ const record = entry as Record<string, unknown>;
111
+ if (configFormat === "mcp") {
68
112
  // OpenCode format: command is array [command, ...args]
69
- const [command = "", ...args] = entry.command;
70
- return { command, args };
113
+ if (
114
+ record.type !== "local" ||
115
+ (record.enabled !== undefined && record.enabled !== true) ||
116
+ !Array.isArray(record.command) ||
117
+ record.command.length === 0 ||
118
+ !record.command.every((part) => typeof part === "string") ||
119
+ Object.keys(record).some(
120
+ (key) =>
121
+ key !== "type" &&
122
+ key !== "command" &&
123
+ key !== "enabled" &&
124
+ key !== "environment"
125
+ )
126
+ ) {
127
+ return null;
128
+ }
129
+ const [command = "", ...args] = record.command;
130
+ if (!command) {
131
+ return null;
132
+ }
133
+ const env = normalizeConnectorWorkspaceEnvironment(record.environment);
134
+ if (env === null) {
135
+ return null;
136
+ }
137
+ return {
138
+ command,
139
+ args,
140
+ ...(Object.keys(env).length > 0 ? { env } : {}),
141
+ };
142
+ }
143
+ if (
144
+ typeof record.command !== "string" ||
145
+ record.command.length === 0 ||
146
+ !Array.isArray(record.args) ||
147
+ !record.args.every((argument) => typeof argument === "string") ||
148
+ Object.keys(record).some(
149
+ (key) => key !== "command" && key !== "args" && key !== "env"
150
+ )
151
+ ) {
152
+ return null;
71
153
  }
72
- return entry as StandardMcpEntry;
154
+ const env = normalizeConnectorWorkspaceEnvironment(record.env);
155
+ if (env === null) {
156
+ return null;
157
+ }
158
+ return {
159
+ command: record.command,
160
+ args: record.args,
161
+ ...(Object.keys(env).length > 0 ? { env } : {}),
162
+ };
163
+ }
164
+
165
+ function entryIdentity(entry: unknown): string {
166
+ const hasher = new Bun.CryptoHasher("sha256");
167
+ hasher.update(JSON.stringify(entry) ?? "undefined");
168
+ return hasher.digest("hex");
73
169
  }
74
170
 
75
171
  export async function checkMcpTargetStatus(
@@ -79,46 +175,84 @@ export async function checkMcpTargetStatus(
79
175
  ): Promise<McpTargetStatus> {
80
176
  const { cwd, homeDir } = options;
81
177
 
82
- const { configPath, configFormat } = resolveMcpConfigPath({
178
+ const resolvedPaths = resolveMcpConfigPath({
83
179
  target,
84
180
  scope,
85
181
  cwd,
86
182
  homeDir,
87
183
  });
88
-
89
- const file = Bun.file(configPath);
90
- const exists = await file.exists();
91
-
92
- if (!exists) {
93
- return { target, scope, configPath, configured: false };
94
- }
184
+ let configPath = resolvedPaths.configPath;
185
+ const { configFormat } = resolvedPaths;
95
186
 
96
187
  try {
97
- const content = await file.text();
188
+ configPath = await resolveMcpConfigLocation(resolvedPaths);
189
+ if (!(await configPathEntryExists(configPath))) {
190
+ return { target, scope, configPath, configured: false };
191
+ }
192
+ const content = await Bun.file(configPath).text();
98
193
  if (!content.trim()) {
99
194
  return { target, scope, configPath, configured: false };
100
195
  }
101
-
102
- const useYaml = isYamlFormat(configFormat);
103
- const config = useYaml
104
- ? (Bun.YAML.parse(content) as AnyMcpConfig)
105
- : (JSON.parse(content) as AnyMcpConfig);
106
- const entry = getServerEntry(config, MCP_SERVER_NAME, configFormat);
107
-
108
- if (entry) {
109
- const serverEntry = normalizeEntry(entry);
110
- return { target, scope, configPath, configured: true, serverEntry };
196
+ const serversKey =
197
+ configFormat === "codex_toml"
198
+ ? "mcp_servers"
199
+ : getServersKey(configFormat);
200
+ const parsed =
201
+ configFormat === "codex_toml"
202
+ ? getTomlServerEntry(content, configPath)
203
+ : configFormat === "yaml_standard"
204
+ ? getYamlServerEntry(content, configPath, serversKey)
205
+ : getJsoncServerEntry(content, configPath, serversKey);
206
+ if (!parsed.exists) {
207
+ return { target, scope, configPath, configured: false };
111
208
  }
112
-
113
- return { target, scope, configPath, configured: false };
114
- } catch {
115
- const format = isYamlFormat(configFormat) ? "YAML" : "JSON";
209
+ const entry = parsed.entry;
210
+ if (
211
+ configFormat === "mcp" &&
212
+ entry &&
213
+ typeof entry === "object" &&
214
+ (entry as { enabled?: unknown }).enabled === false
215
+ ) {
216
+ return { target, scope, configPath, configured: false };
217
+ }
218
+ const configIdentity = entryIdentity(entry);
219
+ const serverEntry = normalizeEntry(entry, configFormat);
220
+ if (!serverEntry) {
221
+ const status: McpTargetStatus = {
222
+ target,
223
+ scope,
224
+ configPath,
225
+ configured: false,
226
+ error: "Malformed MCP server entry",
227
+ };
228
+ configIdentityByStatus.set(status, configIdentity);
229
+ return status;
230
+ }
231
+ const status: McpTargetStatus = {
232
+ target,
233
+ scope,
234
+ configPath,
235
+ configured: true,
236
+ serverEntry,
237
+ };
238
+ configIdentityByStatus.set(status, configIdentity);
239
+ return status;
240
+ } catch (error) {
241
+ const format =
242
+ configFormat === "codex_toml"
243
+ ? "TOML"
244
+ : configFormat === "yaml_standard"
245
+ ? "YAML"
246
+ : "JSON";
116
247
  return {
117
248
  target,
118
249
  scope,
119
250
  configPath,
120
251
  configured: false,
121
- error: `Malformed ${format}`,
252
+ error:
253
+ error instanceof Error && error.message.startsWith("Ambiguous MCP")
254
+ ? error.message
255
+ : `Malformed ${format}`,
122
256
  };
123
257
  }
124
258
  }
@@ -148,31 +282,29 @@ export async function statusMcp(opts: StatusOptions = {}): Promise<void> {
148
282
  const globals = safeGetGlobals();
149
283
  const json = opts.json ?? globals.json;
150
284
 
285
+ if (
286
+ targetFilter !== "all" &&
287
+ scopeFilter !== "all" &&
288
+ !getTargetScopes(targetFilter).includes(scopeFilter)
289
+ ) {
290
+ throw new CliError(
291
+ "VALIDATION",
292
+ `${getTargetDisplayName(targetFilter)} does not support ${scopeFilter} scope.`
293
+ );
294
+ }
295
+
151
296
  const targets: McpTarget[] =
152
297
  targetFilter === "all" ? MCP_TARGETS : [targetFilter];
153
298
 
154
299
  const results: McpTargetStatus[] = [];
155
300
 
156
301
  for (const target of targets) {
157
- const supportsProject = TARGETS_WITH_PROJECT_SCOPE.includes(target);
158
-
159
- if (supportsProject) {
160
- // Targets that support both scopes
161
- const scopes: McpScope[] =
162
- scopeFilter === "all" ? ["user", "project"] : [scopeFilter];
163
-
164
- for (const scope of scopes) {
165
- results.push(
166
- await checkMcpTargetStatus(target, scope, {
167
- cwd: opts.cwd,
168
- homeDir: opts.homeDir,
169
- })
170
- );
171
- }
172
- } else if (scopeFilter === "all" || scopeFilter === "user") {
173
- // User scope only - skip if filtering by project
302
+ const scopes = getTargetScopes(target).filter(
303
+ (scope) => scopeFilter === "all" || scope === scopeFilter
304
+ );
305
+ for (const scope of scopes) {
174
306
  results.push(
175
- await checkMcpTargetStatus(target, "user", {
307
+ await checkMcpTargetStatus(target, scope, {
176
308
  cwd: opts.cwd,
177
309
  homeDir: opts.homeDir,
178
310
  })
@@ -0,0 +1,30 @@
1
+ import type { McpTarget } from "./paths.js";
2
+
3
+ export function getTargetDisplayName(target: McpTarget): string {
4
+ switch (target) {
5
+ case "claude-desktop":
6
+ return "Claude Desktop";
7
+ case "claude-code":
8
+ return "Claude Code";
9
+ case "codex":
10
+ return "Codex";
11
+ case "cursor":
12
+ return "Cursor";
13
+ case "zed":
14
+ return "Zed";
15
+ case "windsurf":
16
+ return "Windsurf";
17
+ case "opencode":
18
+ return "OpenCode";
19
+ case "amp":
20
+ return "Amp";
21
+ case "lmstudio":
22
+ return "LM Studio";
23
+ case "librechat":
24
+ return "LibreChat";
25
+ default: {
26
+ const exhaustive: never = target;
27
+ throw new Error(`Unknown target: ${String(exhaustive)}`);
28
+ }
29
+ }
30
+ }
@@ -6,20 +6,24 @@
6
6
 
7
7
  import { CliError } from "../../errors.js";
8
8
  import { getGlobals } from "../../program.js";
9
+ import { resolveMcpConfigLocation } from "./config-discovery.js";
9
10
  import {
10
- type AnyMcpConfig,
11
- isYamlFormat,
12
- readMcpConfig,
13
- removeServerEntry,
14
- writeMcpConfig,
11
+ removeJsoncServerEntry,
12
+ removeTomlServerEntry,
13
+ removeYamlServerEntry,
14
+ } from "./config-editors.js";
15
+ import {
16
+ getServersKey,
17
+ readMcpConfigText,
18
+ writeMcpConfigText,
15
19
  } from "./config.js";
16
20
  import {
21
+ getDefaultTargetScope,
22
+ getTargetScopes,
17
23
  getTargetDisplayName,
18
- MCP_SERVER_NAME,
19
24
  type McpScope,
20
25
  type McpTarget,
21
26
  resolveMcpConfigPath,
22
- TARGETS_WITH_PROJECT_SCOPE,
23
27
  } from "./paths.js";
24
28
 
25
29
  // ─────────────────────────────────────────────────────────────────────────────
@@ -60,38 +64,32 @@ async function uninstallFromTarget(
60
64
  ): Promise<UninstallResult> {
61
65
  const { cwd, homeDir } = options;
62
66
 
63
- const { configPath, configFormat } = resolveMcpConfigPath({
67
+ const resolvedPaths = resolveMcpConfigPath({
64
68
  target,
65
69
  scope,
66
70
  cwd,
67
71
  homeDir,
68
72
  });
69
-
70
- const useYaml = isYamlFormat(configFormat);
71
- const config = await readMcpConfig(configPath, {
73
+ const configPath = await resolveMcpConfigLocation(resolvedPaths);
74
+ const { configFormat } = resolvedPaths;
75
+ const content = await readMcpConfigText(configPath, {
72
76
  returnNullOnMissing: true,
73
- yaml: useYaml,
74
77
  });
75
-
76
- // File doesn't exist
77
- if (config === null) {
78
+ if (content === null) {
78
79
  return { target, scope, configPath, action: "not_found" };
79
80
  }
80
-
81
- // Try to remove entry using format-aware helper
82
- const removed = removeServerEntry(
83
- config as AnyMcpConfig,
84
- MCP_SERVER_NAME,
85
- configFormat
86
- );
87
-
88
- if (!removed) {
81
+ const serversKey =
82
+ configFormat === "codex_toml" ? "mcp_servers" : getServersKey(configFormat);
83
+ const result =
84
+ configFormat === "codex_toml"
85
+ ? removeTomlServerEntry(content, configPath)
86
+ : configFormat === "yaml_standard"
87
+ ? removeYamlServerEntry(content, configPath, serversKey)
88
+ : removeJsoncServerEntry(content, configPath, serversKey);
89
+ if (!result.removed) {
89
90
  return { target, scope, configPath, action: "not_found" };
90
91
  }
91
-
92
- // Write back
93
- await writeMcpConfig(configPath, config as AnyMcpConfig, { yaml: useYaml });
94
-
92
+ await writeMcpConfigText(configPath, result.content);
95
93
  return { target, scope, configPath, action: "removed" };
96
94
  }
97
95
 
@@ -111,16 +109,16 @@ function safeGetGlobals(): { json: boolean; quiet: boolean } {
111
109
  */
112
110
  export async function uninstallMcp(opts: UninstallOptions = {}): Promise<void> {
113
111
  const target = opts.target ?? "claude-desktop";
114
- const scope = opts.scope ?? "user";
112
+ const scope = opts.scope ?? getDefaultTargetScope(target);
115
113
  const globals = safeGetGlobals();
116
114
  const json = opts.json ?? globals.json;
117
115
  const quiet = opts.quiet ?? globals.quiet;
118
116
 
119
117
  // Validate scope - only some targets support project scope
120
- if (scope === "project" && !TARGETS_WITH_PROJECT_SCOPE.includes(target)) {
118
+ if (!getTargetScopes(target).includes(scope)) {
121
119
  throw new CliError(
122
120
  "VALIDATION",
123
- `${getTargetDisplayName(target)} does not support project scope.`
121
+ `${getTargetDisplayName(target)} does not support ${scope} scope.`
124
122
  );
125
123
  }
126
124