@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,12 +5,13 @@
5
5
  * @module src/cli/commands/mcp/config
6
6
  */
7
7
 
8
- import { copyFile, mkdir, rename, stat, unlink } from "node:fs/promises";
9
- import { dirname } from "node:path";
8
+ import { copyFile, lstat, stat } from "node:fs/promises";
10
9
 
10
+ import type { ConnectorWorkspaceEnvironment } from "../../../core/connector-environment.js";
11
11
  import type { McpConfigFormat } from "./paths.js";
12
12
 
13
13
  import { CliError } from "../../errors.js";
14
+ import { writeMcpConfigTextAtomically } from "./atomic-config-write.js";
14
15
 
15
16
  // ─────────────────────────────────────────────────────────────────────────────
16
17
  // Types
@@ -20,6 +21,7 @@ import { CliError } from "../../errors.js";
20
21
  export interface StandardMcpEntry {
21
22
  command: string;
22
23
  args: string[];
24
+ env?: ConnectorWorkspaceEnvironment;
23
25
  }
24
26
 
25
27
  /** OpenCode mcp entry (command is array, has type and enabled) */
@@ -27,6 +29,7 @@ export interface OpenCodeMcpEntry {
27
29
  type: "local";
28
30
  command: string[];
29
31
  enabled: boolean;
32
+ environment?: ConnectorWorkspaceEnvironment;
30
33
  }
31
34
 
32
35
  /** Config with standard mcpServers key */
@@ -68,8 +71,29 @@ async function checkConfigPath(
68
71
  configPath: string
69
72
  ): Promise<"file" | "missing"> {
70
73
  try {
71
- const stats = await stat(configPath);
72
- if (!stats.isFile()) {
74
+ const pathStats = await lstat(configPath);
75
+ if (pathStats.isSymbolicLink()) {
76
+ let targetStats: Awaited<ReturnType<typeof stat>>;
77
+ try {
78
+ targetStats = await stat(configPath);
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
81
+ throw new CliError(
82
+ "RUNTIME",
83
+ `Config path is a dangling symbolic link: ${configPath}`
84
+ );
85
+ }
86
+ throw error;
87
+ }
88
+ if (!targetStats.isFile()) {
89
+ throw new CliError(
90
+ "RUNTIME",
91
+ `Config symbolic link target is not a file: ${configPath}`
92
+ );
93
+ }
94
+ return "file";
95
+ }
96
+ if (!pathStats.isFile()) {
73
97
  throw new CliError(
74
98
  "RUNTIME",
75
99
  `Config path exists but is not a file: ${configPath}`
@@ -84,88 +108,49 @@ async function checkConfigPath(
84
108
  }
85
109
  }
86
110
 
87
- /**
88
- * Read and parse MCP config file.
89
- * Returns empty config if file doesn't exist.
90
- * Returns null if file doesn't exist AND returnNullOnMissing is true.
91
- * Throws on malformed JSON/YAML or if path is a directory.
92
- */
93
- export async function readMcpConfig(
111
+ /** Read config text without parsing so format-specific editors preserve layout. */
112
+ export async function readMcpConfigText(
94
113
  configPath: string,
95
- options?: { returnNullOnMissing?: boolean; yaml?: boolean }
96
- ): Promise<McpConfig | null> {
114
+ options?: { returnNullOnMissing?: boolean }
115
+ ): Promise<string | null> {
97
116
  const pathStatus = await checkConfigPath(configPath);
98
-
99
117
  if (pathStatus === "missing") {
100
- return options?.returnNullOnMissing ? null : {};
101
- }
102
-
103
- const file = Bun.file(configPath);
104
- const content = await file.text();
105
-
106
- // Handle empty file
107
- if (!content.trim()) {
108
- return {};
109
- }
110
-
111
- try {
112
- if (options?.yaml) {
113
- return Bun.YAML.parse(content) as McpConfig;
114
- }
115
- return JSON.parse(content) as McpConfig;
116
- } catch {
117
- const format = options?.yaml ? "YAML" : "JSON";
118
- throw new CliError(
119
- "RUNTIME",
120
- `Malformed ${format} in ${configPath}. Please fix or backup and delete the file.`
121
- );
118
+ return options?.returnNullOnMissing ? null : "";
122
119
  }
120
+ return Bun.file(configPath).text();
123
121
  }
124
122
 
125
- /**
126
- * Write MCP config atomically via temp file + rename.
127
- * Creates backup of existing file first.
128
- */
129
- export async function writeMcpConfig(
123
+ /** Write MCP text atomically while preserving the same backup contract. */
124
+ export async function writeMcpConfigText(
130
125
  configPath: string,
131
- config: AnyMcpConfig,
132
- options?: { yaml?: boolean }
126
+ content: string
133
127
  ): Promise<void> {
134
- const dir = dirname(configPath);
135
-
136
- // Ensure directory exists
137
- await mkdir(dir, { recursive: true });
138
-
139
- // Create backup of existing file
128
+ const backupPath = `${configPath}.bak`;
140
129
  try {
141
130
  const stats = await stat(configPath);
142
131
  if (stats.isFile()) {
143
- await copyFile(configPath, `${configPath}.bak`);
132
+ try {
133
+ const backupStats = await lstat(backupPath);
134
+ if (!backupStats.isFile() || backupStats.isSymbolicLink()) {
135
+ throw new CliError(
136
+ "RUNTIME",
137
+ `MCP config backup path is not a regular file: ${backupPath}`
138
+ );
139
+ }
140
+ } catch (error) {
141
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
142
+ throw error;
143
+ }
144
+ }
145
+ await copyFile(configPath, backupPath);
144
146
  }
145
- } catch {
146
- // File doesn't exist, no backup needed
147
- }
148
-
149
- // Serialize content
150
- const content = options?.yaml
151
- ? Bun.YAML.stringify(config)
152
- : JSON.stringify(config, null, 2);
153
-
154
- // Write to temp file first
155
- const tmpPath = `${configPath}.tmp.${Date.now()}.${process.pid}`;
156
- try {
157
- await Bun.write(tmpPath, content);
158
- // Atomic rename
159
- await rename(tmpPath, configPath);
160
- } catch (err) {
161
- // Cleanup temp file on error
162
- try {
163
- await unlink(tmpPath);
164
- } catch {
165
- // Ignore cleanup errors
147
+ } catch (error) {
148
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
149
+ throw error;
166
150
  }
167
- throw err;
168
151
  }
152
+
153
+ await writeMcpConfigTextAtomically(configPath, content);
169
154
  }
170
155
 
171
156
  // ─────────────────────────────────────────────────────────────────────────────
@@ -188,6 +173,8 @@ export function getServersKey(
188
173
  return "mcp";
189
174
  case "amp_mcp":
190
175
  return "amp.mcpServers";
176
+ case "codex_toml":
177
+ throw new Error("Codex TOML uses dedicated config operations");
191
178
  default: {
192
179
  const _exhaustive: never = format;
193
180
  throw new Error(`Unknown format: ${String(_exhaustive)}`);
@@ -195,48 +182,14 @@ export function getServersKey(
195
182
  }
196
183
  }
197
184
 
198
- /**
199
- * Check if format uses YAML.
200
- */
201
- export function isYamlFormat(format: McpConfigFormat): boolean {
202
- return format === "yaml_standard";
203
- }
204
-
205
- /**
206
- * Check if a server entry exists in config for given format.
207
- */
208
- export function hasServerEntry(
209
- config: AnyMcpConfig,
210
- serverName: string,
211
- format: McpConfigFormat
212
- ): boolean {
213
- const key = getServersKey(format);
214
- const servers = config[key] as Record<string, unknown> | undefined;
215
- return !!servers?.[serverName];
216
- }
217
-
218
- /**
219
- * Get server entry from config.
220
- */
221
- export function getServerEntry(
222
- config: AnyMcpConfig,
223
- serverName: string,
224
- format: McpConfigFormat
225
- ): StandardMcpEntry | OpenCodeMcpEntry | undefined {
226
- const key = getServersKey(format);
227
- const servers = config[key] as
228
- | Record<string, StandardMcpEntry | OpenCodeMcpEntry>
229
- | undefined;
230
- return servers?.[serverName];
231
- }
232
-
233
185
  /**
234
186
  * Build entry for a specific format from standard command/args.
235
187
  */
236
188
  export function buildEntry(
237
189
  command: string,
238
190
  args: string[],
239
- format: McpConfigFormat
191
+ format: McpConfigFormat,
192
+ env?: ConnectorWorkspaceEnvironment
240
193
  ): StandardMcpEntry | OpenCodeMcpEntry {
241
194
  if (format === "mcp") {
242
195
  // OpenCode: command is array [command, ...args]
@@ -244,59 +197,9 @@ export function buildEntry(
244
197
  type: "local",
245
198
  command: [command, ...args],
246
199
  enabled: true,
200
+ ...(env ? { environment: env } : {}),
247
201
  };
248
202
  }
249
203
  // All others use standard format
250
- return { command, args };
251
- }
252
-
253
- /**
254
- * Add or update server entry in config.
255
- */
256
- export function setServerEntry(
257
- config: AnyMcpConfig,
258
- serverName: string,
259
- entry: StandardMcpEntry | OpenCodeMcpEntry,
260
- format: McpConfigFormat
261
- ): void {
262
- const key = getServersKey(format);
263
-
264
- // Initialize servers record if needed
265
- if (!config[key]) {
266
- (config as Record<string, unknown>)[key] = {};
267
- }
268
-
269
- const servers = config[key] as Record<
270
- string,
271
- StandardMcpEntry | OpenCodeMcpEntry
272
- >;
273
- servers[serverName] = entry;
274
- }
275
-
276
- /**
277
- * Remove server entry from config.
278
- * Returns true if entry was removed, false if not found.
279
- */
280
- export function removeServerEntry(
281
- config: AnyMcpConfig,
282
- serverName: string,
283
- format: McpConfigFormat
284
- ): boolean {
285
- const key = getServersKey(format);
286
- const servers = config[key] as
287
- | Record<string, StandardMcpEntry | OpenCodeMcpEntry>
288
- | undefined;
289
-
290
- if (!servers?.[serverName]) {
291
- return false;
292
- }
293
-
294
- delete servers[serverName];
295
-
296
- // Clean up empty servers object
297
- if (Object.keys(servers).length === 0) {
298
- delete (config as Record<string, unknown>)[key];
299
- }
300
-
301
- return true;
204
+ return { command, args, ...(env ? { env } : {}) };
302
205
  }
@@ -4,27 +4,36 @@
4
4
  * @module src/cli/commands/mcp/install
5
5
  */
6
6
 
7
+ import { DEFAULT_INDEX_NAME } from "../../../app/constants.js";
8
+ import { getConfigPaths, toAbsolutePath } from "../../../config/paths.js";
7
9
  import { CliError } from "../../errors.js";
8
10
  import { getGlobals } from "../../program.js";
11
+ import { resolveMcpConfigLocation } from "./config-discovery.js";
12
+ import {
13
+ getJsoncServerEntry,
14
+ getTomlServerEntry,
15
+ getYamlServerEntry,
16
+ setJsoncServerEntry,
17
+ setTomlServerEntry,
18
+ setYamlServerEntry,
19
+ } from "./config-editors.js";
9
20
  import {
10
- type AnyMcpConfig,
11
21
  buildEntry,
12
- hasServerEntry,
13
- isYamlFormat,
14
- readMcpConfig,
15
- type StandardMcpEntry,
16
- setServerEntry,
17
- writeMcpConfig,
22
+ getServersKey,
23
+ readMcpConfigText,
24
+ writeMcpConfigText,
18
25
  } from "./config.js";
19
26
  import {
20
27
  buildMcpServerEntry,
28
+ getDefaultTargetScope,
29
+ getTargetScopes,
21
30
  getTargetDisplayName,
22
- MCP_SERVER_NAME,
23
31
  type McpScope,
32
+ type McpServerEntry,
24
33
  type McpTarget,
25
34
  resolveMcpConfigPath,
26
- TARGETS_WITH_PROJECT_SCOPE,
27
35
  } from "./paths.js";
36
+ import { normalizeMcpServerEntryForInstall } from "./server-entry.js";
28
37
 
29
38
  // ─────────────────────────────────────────────────────────────────────────────
30
39
  // Types
@@ -36,6 +45,10 @@ export interface InstallOptions {
36
45
  force?: boolean;
37
46
  dryRun?: boolean;
38
47
  enableWrite?: boolean;
48
+ /** Index identity persisted in the installed MCP command. */
49
+ indexName?: string;
50
+ /** Active GNO config persisted as a canonical absolute path. */
51
+ configPath?: string;
39
52
  /** Override cwd (testing) */
40
53
  cwd?: string;
41
54
  /** Override home dir (testing) */
@@ -51,7 +64,7 @@ export interface McpInstallResult {
51
64
  scope: McpScope;
52
65
  configPath: string;
53
66
  action: "created" | "updated" | "dry_run_create" | "dry_run_update";
54
- serverEntry: { command: string; args: string[] };
67
+ serverEntry: McpServerEntry;
55
68
  }
56
69
 
57
70
  // ─────────────────────────────────────────────────────────────────────────────
@@ -64,7 +77,7 @@ export interface McpInstallResult {
64
77
  export async function installMcpToTarget(
65
78
  target: McpTarget,
66
79
  scope: McpScope,
67
- serverEntry: StandardMcpEntry,
80
+ serverEntry: McpServerEntry,
68
81
  options: {
69
82
  force?: boolean;
70
83
  dryRun?: boolean;
@@ -73,23 +86,25 @@ export async function installMcpToTarget(
73
86
  }
74
87
  ): Promise<McpInstallResult> {
75
88
  const { force = false, dryRun = false, cwd, homeDir } = options;
76
-
77
- const { configPath, configFormat } = resolveMcpConfigPath({
89
+ const normalizedEntry = normalizeMcpServerEntryForInstall(serverEntry);
90
+ const resolvedPaths = resolveMcpConfigPath({
78
91
  target,
79
92
  scope,
80
93
  cwd,
81
94
  homeDir,
82
95
  });
83
-
84
- // Read existing config (needed for both dry-run preview and actual install)
85
- // readMcpConfig returns {} for missing files when returnNullOnMissing is not set
86
- const useYaml = isYamlFormat(configFormat);
87
- const config = ((await readMcpConfig(configPath, {
88
- yaml: useYaml,
89
- })) ?? {}) as AnyMcpConfig;
90
-
91
- // Check if already exists using format-aware helper
92
- const alreadyExists = hasServerEntry(config, MCP_SERVER_NAME, configFormat);
96
+ const configPath = await resolveMcpConfigLocation(resolvedPaths);
97
+ const { configFormat } = resolvedPaths;
98
+ const content = (await readMcpConfigText(configPath)) ?? "";
99
+ const serversKey =
100
+ configFormat === "codex_toml" ? "mcp_servers" : getServersKey(configFormat);
101
+ const parsed =
102
+ configFormat === "codex_toml"
103
+ ? getTomlServerEntry(content, configPath)
104
+ : configFormat === "yaml_standard"
105
+ ? getYamlServerEntry(content, configPath, serversKey)
106
+ : getJsoncServerEntry(content, configPath, serversKey);
107
+ const alreadyExists = parsed.exists;
93
108
  if (alreadyExists && !force) {
94
109
  throw new CliError(
95
110
  "VALIDATION",
@@ -99,33 +114,44 @@ export async function installMcpToTarget(
99
114
  );
100
115
  }
101
116
 
102
- const wouldCreate = !alreadyExists;
117
+ const entry = buildEntry(
118
+ normalizedEntry.command,
119
+ normalizedEntry.args,
120
+ configFormat,
121
+ normalizedEntry.env
122
+ );
123
+ const standardEntry = {
124
+ command: normalizedEntry.command,
125
+ args: normalizedEntry.args,
126
+ ...(normalizedEntry.env ? { env: normalizedEntry.env } : {}),
127
+ };
128
+ const updated =
129
+ configFormat === "codex_toml"
130
+ ? setTomlServerEntry(content, configPath, standardEntry)
131
+ : configFormat === "yaml_standard"
132
+ ? setYamlServerEntry(content, configPath, serversKey, standardEntry)
133
+ : setJsoncServerEntry(content, configPath, serversKey, entry);
103
134
 
135
+ const wouldCreate = !alreadyExists;
104
136
  if (dryRun) {
105
137
  return {
106
138
  target,
107
139
  scope,
108
140
  configPath,
109
141
  action: wouldCreate ? "dry_run_create" : "dry_run_update",
110
- serverEntry,
142
+ serverEntry: normalizedEntry,
111
143
  };
112
144
  }
113
145
 
114
146
  const action = wouldCreate ? "created" : "updated";
115
-
116
- // Build format-specific entry and add to config
117
- const entry = buildEntry(serverEntry.command, serverEntry.args, configFormat);
118
- setServerEntry(config, MCP_SERVER_NAME, entry, configFormat);
119
-
120
- // Write atomically
121
- await writeMcpConfig(configPath, config, { yaml: useYaml });
147
+ await writeMcpConfigText(configPath, updated);
122
148
 
123
149
  return {
124
150
  target,
125
151
  scope,
126
152
  configPath,
127
153
  action,
128
- serverEntry,
154
+ serverEntry: normalizedEntry,
129
155
  };
130
156
  }
131
157
 
@@ -145,24 +171,36 @@ function safeGetGlobals(): { json: boolean; quiet: boolean } {
145
171
  */
146
172
  export async function installMcp(opts: InstallOptions = {}): Promise<void> {
147
173
  const target = opts.target ?? "claude-desktop";
148
- const scope = opts.scope ?? "user";
174
+ const scope = opts.scope ?? getDefaultTargetScope(target);
149
175
  const force = opts.force ?? false;
150
176
  const dryRun = opts.dryRun ?? false;
151
177
  const enableWrite = opts.enableWrite ?? false;
178
+ const indexName = opts.indexName ?? DEFAULT_INDEX_NAME;
179
+ const paths = getConfigPaths();
180
+ const configPath = toAbsolutePath(
181
+ opts.configPath ?? paths.configFile,
182
+ opts.cwd
183
+ );
152
184
  const globals = safeGetGlobals();
153
185
  const json = opts.json ?? globals.json;
154
186
  const quiet = opts.quiet ?? globals.quiet;
155
187
 
156
188
  // Validate scope - only some targets support project scope
157
- if (scope === "project" && !TARGETS_WITH_PROJECT_SCOPE.includes(target)) {
189
+ if (!getTargetScopes(target).includes(scope)) {
158
190
  throw new CliError(
159
191
  "VALIDATION",
160
- `${getTargetDisplayName(target)} does not support project scope. Use --scope user.`
192
+ `${getTargetDisplayName(target)} does not support ${scope} scope.`
161
193
  );
162
194
  }
163
195
 
164
196
  // Build server entry (uses process.execPath, always succeeds)
165
- const serverEntry = buildMcpServerEntry({ enableWrite });
197
+ const serverEntry = buildMcpServerEntry({
198
+ enableWrite,
199
+ indexName,
200
+ configPath,
201
+ dataDir: toAbsolutePath(paths.dataDir, opts.cwd),
202
+ cacheDir: toAbsolutePath(paths.cacheDir, opts.cwd),
203
+ });
166
204
 
167
205
  // Install
168
206
  const result = await installMcpToTarget(target, scope, serverEntry, {