@gmickel/gno 1.12.3 → 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 (69) hide show
  1. package/README.md +57 -30
  2. package/assets/skill/SKILL.md +6 -1
  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/context-resolver.ts +285 -0
  37. package/src/core/indexed-reference.ts +33 -8
  38. package/src/core/runtime-entrypoint.ts +24 -0
  39. package/src/mcp/activation-verification-mode.ts +4 -0
  40. package/src/mcp/server.ts +9 -2
  41. package/src/mcp/tools/index.ts +3 -3
  42. package/src/pipeline/answer-prompt.ts +80 -0
  43. package/src/pipeline/answer.ts +12 -26
  44. package/src/pipeline/hybrid.ts +2 -0
  45. package/src/pipeline/result-context.ts +51 -0
  46. package/src/pipeline/search.ts +5 -1
  47. package/src/pipeline/vsearch.ts +2 -0
  48. package/src/sdk/client.ts +7 -0
  49. package/src/sdk/types.ts +1 -0
  50. package/src/serve/activation-health.ts +91 -0
  51. package/src/serve/background-runtime.ts +11 -1
  52. package/src/serve/connectors.ts +164 -19
  53. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  54. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  55. package/src/serve/public/components/HealthCenter.tsx +8 -2
  56. package/src/serve/public/globals.built.css +1 -1
  57. package/src/serve/public/pages/Connectors.tsx +216 -55
  58. package/src/serve/public/pages/Dashboard.tsx +1 -0
  59. package/src/serve/routes/api.ts +152 -8
  60. package/src/serve/server.ts +44 -9
  61. package/src/serve/status-model.ts +4 -0
  62. package/src/serve/status.ts +79 -35
  63. package/src/store/activation-receipts.ts +390 -0
  64. package/src/store/index.ts +8 -0
  65. package/src/store/migrations/012-activation-receipts.ts +38 -0
  66. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  67. package/src/store/migrations/index.ts +4 -0
  68. package/src/store/sqlite/adapter.ts +320 -53
  69. package/src/store/types.ts +124 -0
@@ -0,0 +1,432 @@
1
+ /** Comment-preserving MCP client config readers and targeted editors. */
2
+
3
+ import {
4
+ applyEdits,
5
+ type FormattingOptions,
6
+ getNodeValue,
7
+ modify,
8
+ type Node,
9
+ type ParseError,
10
+ parseTree,
11
+ printParseErrorCode,
12
+ } from "jsonc-parser";
13
+
14
+ import type { AnyMcpConfig, StandardMcpEntry } from "./config.js";
15
+
16
+ import { CliError } from "../../errors.js";
17
+
18
+ export interface ParsedServerEntry {
19
+ exists: boolean;
20
+ entry?: unknown;
21
+ }
22
+
23
+ export function isPlainRecord(
24
+ value: unknown
25
+ ): value is Record<string, unknown> {
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
27
+ return false;
28
+ }
29
+ const prototype = Object.getPrototypeOf(value);
30
+ return prototype === Object.prototype || prototype === null;
31
+ }
32
+
33
+ function validateRootAndServers(
34
+ root: unknown,
35
+ serversKey: string,
36
+ configPath: string,
37
+ format: string
38
+ ): Record<string, unknown> | undefined {
39
+ if (!isPlainRecord(root)) {
40
+ throw new CliError(
41
+ "RUNTIME",
42
+ `${format} root in ${configPath} must be an object.`
43
+ );
44
+ }
45
+ const servers = root[serversKey];
46
+ if (servers === undefined) {
47
+ return undefined;
48
+ }
49
+ if (!isPlainRecord(servers)) {
50
+ throw new CliError(
51
+ "RUNTIME",
52
+ `${serversKey} in ${configPath} must be an object.`
53
+ );
54
+ }
55
+ return servers;
56
+ }
57
+
58
+ function jsoncFormatting(content: string): FormattingOptions {
59
+ const indentMatch = content.match(/\n([ \t]+)\S/u)?.[1];
60
+ return {
61
+ insertSpaces: !indentMatch?.includes("\t"),
62
+ tabSize: indentMatch?.includes("\t") ? 1 : (indentMatch?.length ?? 2),
63
+ eol: content.includes("\r\n") ? "\r\n" : "\n",
64
+ };
65
+ }
66
+
67
+ interface ParsedJsoncDocument {
68
+ root: AnyMcpConfig;
69
+ rootNode?: Node;
70
+ }
71
+
72
+ function jsoncPropertyNodes(objectNode: Node, propertyName: string): Node[] {
73
+ return (objectNode.children ?? []).filter(
74
+ (property) =>
75
+ property.type === "property" &&
76
+ property.children?.[0]?.value === propertyName
77
+ );
78
+ }
79
+
80
+ function getUniqueJsoncPropertyValue(
81
+ objectNode: Node,
82
+ propertyName: string,
83
+ configPath: string,
84
+ location: string
85
+ ): Node | undefined {
86
+ const properties = jsoncPropertyNodes(objectNode, propertyName);
87
+ if (properties.length > 1) {
88
+ throw new CliError(
89
+ "RUNTIME",
90
+ `Ambiguous MCP JSONC config ${configPath}: duplicate "${propertyName}" key in ${location}.`
91
+ );
92
+ }
93
+ return properties[0]?.children?.[1];
94
+ }
95
+
96
+ function parseJsoncRoot(
97
+ content: string,
98
+ configPath: string
99
+ ): ParsedJsoncDocument {
100
+ if (!content.trim()) {
101
+ return { root: {} };
102
+ }
103
+ const errors: ParseError[] = [];
104
+ const rootNode = parseTree(content, errors, {
105
+ allowTrailingComma: true,
106
+ disallowComments: false,
107
+ allowEmptyContent: true,
108
+ });
109
+ if (errors.length > 0) {
110
+ const first = errors[0];
111
+ throw new CliError(
112
+ "RUNTIME",
113
+ `Malformed JSONC in ${configPath}: ${first ? printParseErrorCode(first.error) : "parse error"}.`
114
+ );
115
+ }
116
+ const root = rootNode ? getNodeValue(rootNode) : undefined;
117
+ if (!rootNode || rootNode.type !== "object" || !isPlainRecord(root)) {
118
+ throw new CliError(
119
+ "RUNTIME",
120
+ `JSONC root in ${configPath} must be an object.`
121
+ );
122
+ }
123
+ return { root: root as AnyMcpConfig, rootNode };
124
+ }
125
+
126
+ export function getJsoncServerEntry(
127
+ content: string,
128
+ configPath: string,
129
+ serversKey: string
130
+ ): ParsedServerEntry {
131
+ const { root, rootNode } = parseJsoncRoot(content, configPath);
132
+ const serversNode = rootNode
133
+ ? getUniqueJsoncPropertyValue(
134
+ rootNode,
135
+ serversKey,
136
+ configPath,
137
+ "the root object"
138
+ )
139
+ : undefined;
140
+ const servers = validateRootAndServers(root, serversKey, configPath, "JSONC");
141
+ if (serversNode?.type === "object") {
142
+ getUniqueJsoncPropertyValue(
143
+ serversNode,
144
+ "gno",
145
+ configPath,
146
+ `the "${serversKey}" server map`
147
+ );
148
+ }
149
+ return servers && Object.hasOwn(servers, "gno")
150
+ ? { exists: true, entry: servers.gno }
151
+ : { exists: false };
152
+ }
153
+
154
+ export function setJsoncServerEntry(
155
+ content: string,
156
+ configPath: string,
157
+ serversKey: string,
158
+ entry: unknown
159
+ ): string {
160
+ getJsoncServerEntry(content, configPath, serversKey);
161
+ const edits = modify(content, [serversKey, "gno"], entry, {
162
+ formattingOptions: jsoncFormatting(content),
163
+ });
164
+ const updated = applyEdits(content, edits);
165
+ getJsoncServerEntry(updated, configPath, serversKey);
166
+ return updated.endsWith("\n")
167
+ ? updated
168
+ : `${updated}${jsoncFormatting(content).eol}`;
169
+ }
170
+
171
+ export function removeJsoncServerEntry(
172
+ content: string,
173
+ configPath: string,
174
+ serversKey: string
175
+ ): { content: string; removed: boolean } {
176
+ const parsed = getJsoncServerEntry(content, configPath, serversKey);
177
+ if (!parsed.exists) {
178
+ return { content, removed: false };
179
+ }
180
+ const { root } = parseJsoncRoot(content, configPath);
181
+ const servers = validateRootAndServers(root, serversKey, configPath, "JSONC");
182
+ const path =
183
+ servers && Object.keys(servers).length === 1
184
+ ? [serversKey]
185
+ : [serversKey, "gno"];
186
+ const updated = applyEdits(
187
+ content,
188
+ modify(content, path, undefined, {
189
+ formattingOptions: jsoncFormatting(content),
190
+ })
191
+ );
192
+ getJsoncServerEntry(updated, configPath, serversKey);
193
+ return { content: updated, removed: true };
194
+ }
195
+
196
+ export {
197
+ getYamlServerEntry,
198
+ removeYamlServerEntry,
199
+ setYamlServerEntry,
200
+ } from "./yaml-config-editor.js";
201
+
202
+ const TOML_SECTION_PATTERN = /^\s*\[([^\]]+)]\s*(?:#.*)?$/;
203
+ const TOML_GNO_SECTION_PATTERN =
204
+ /^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\.\s*(?:gno|"gno"|'gno')(?:\s*\.\s*(?:env|"env"|'env'))?\s*$/;
205
+ const TOML_GNO_DESCENDANT_PATTERN =
206
+ /^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')\s*\.\s*(?:gno|"gno"|'gno')\s*\./;
207
+
208
+ interface TomlLine {
209
+ text: string;
210
+ isTable: boolean;
211
+ isGnoTable: boolean;
212
+ isUnsupportedGnoTable: boolean;
213
+ }
214
+
215
+ function isEscaped(value: string, position: number): boolean {
216
+ let slashes = 0;
217
+ for (
218
+ let cursor = position - 1;
219
+ cursor >= 0 && value[cursor] === "\\";
220
+ cursor -= 1
221
+ ) {
222
+ slashes += 1;
223
+ }
224
+ return slashes % 2 === 1;
225
+ }
226
+
227
+ function nextMultilineState(
228
+ line: string,
229
+ current: '"""' | "'''" | null
230
+ ): '"""' | "'''" | null {
231
+ let position = 0;
232
+ if (current) {
233
+ let closing = line.indexOf(current);
234
+ while (closing !== -1 && current === '"""' && isEscaped(line, closing)) {
235
+ closing = line.indexOf(current, closing + 3);
236
+ }
237
+ if (closing === -1) {
238
+ return current;
239
+ }
240
+ position = closing + 3;
241
+ }
242
+
243
+ while (position < line.length) {
244
+ const character = line[position];
245
+ if (character === "#") {
246
+ return null;
247
+ }
248
+ if (character === '"') {
249
+ if (line.startsWith('"""', position)) {
250
+ let closing = line.indexOf('"""', position + 3);
251
+ while (closing !== -1 && isEscaped(line, closing)) {
252
+ closing = line.indexOf('"""', closing + 3);
253
+ }
254
+ if (closing === -1) {
255
+ return '"""';
256
+ }
257
+ position = closing + 3;
258
+ continue;
259
+ }
260
+ position += 1;
261
+ while (position < line.length) {
262
+ if (line[position] === '"' && !isEscaped(line, position)) {
263
+ position += 1;
264
+ break;
265
+ }
266
+ position += 1;
267
+ }
268
+ continue;
269
+ }
270
+ if (character === "'") {
271
+ if (line.startsWith("'''", position)) {
272
+ const closing = line.indexOf("'''", position + 3);
273
+ if (closing === -1) {
274
+ return "'''";
275
+ }
276
+ position = closing + 3;
277
+ continue;
278
+ }
279
+ const closing = line.indexOf("'", position + 1);
280
+ position = closing === -1 ? line.length : closing + 1;
281
+ continue;
282
+ }
283
+ position += 1;
284
+ }
285
+ return null;
286
+ }
287
+
288
+ function scanTomlLines(content: string): TomlLine[] {
289
+ let multiline: '"""' | "'''" | null = null;
290
+ return content.split(/\r?\n/).map((text) => {
291
+ const outside = multiline === null;
292
+ const section = outside ? text.match(TOML_SECTION_PATTERN)?.[1] : undefined;
293
+ const line: TomlLine = {
294
+ text,
295
+ isTable: section !== undefined,
296
+ isGnoTable:
297
+ section !== undefined && TOML_GNO_SECTION_PATTERN.test(section),
298
+ isUnsupportedGnoTable:
299
+ section !== undefined &&
300
+ TOML_GNO_DESCENDANT_PATTERN.test(section) &&
301
+ !TOML_GNO_SECTION_PATTERN.test(section),
302
+ };
303
+
304
+ multiline = nextMultilineState(text, multiline);
305
+ return line;
306
+ });
307
+ }
308
+
309
+ function parseTomlRoot(content: string, configPath: string): AnyMcpConfig {
310
+ if (!content.trim()) {
311
+ return {};
312
+ }
313
+ try {
314
+ const root = Bun.TOML.parse(content);
315
+ if (!isPlainRecord(root)) {
316
+ throw new Error("non-record root");
317
+ }
318
+ return root as AnyMcpConfig;
319
+ } catch {
320
+ throw new CliError("RUNTIME", `Malformed TOML in ${configPath}.`);
321
+ }
322
+ }
323
+
324
+ export function getTomlServerEntry(
325
+ content: string,
326
+ configPath: string
327
+ ): ParsedServerEntry {
328
+ const root = parseTomlRoot(content, configPath);
329
+ const servers = validateRootAndServers(
330
+ root,
331
+ "mcp_servers",
332
+ configPath,
333
+ "TOML"
334
+ );
335
+ return servers && Object.hasOwn(servers, "gno")
336
+ ? { exists: true, entry: servers.gno }
337
+ : { exists: false };
338
+ }
339
+
340
+ function removeTomlGnoSections(
341
+ content: string,
342
+ configPath: string
343
+ ): { content: string; removed: boolean; newline: string } {
344
+ const lines = scanTomlLines(content);
345
+ if (lines.some(({ isUnsupportedGnoTable }) => isUnsupportedGnoTable)) {
346
+ throw new CliError(
347
+ "RUNTIME",
348
+ `Unsupported nested GNO MCP entry in ${configPath}.`
349
+ );
350
+ }
351
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
352
+ const kept: string[] = [];
353
+ let removing = false;
354
+ let removed = false;
355
+ for (const line of lines) {
356
+ if (line.isTable) {
357
+ removing = line.isGnoTable;
358
+ removed ||= removing;
359
+ }
360
+ if (!removing || line.text.trimStart().startsWith("#")) {
361
+ kept.push(line.text);
362
+ }
363
+ }
364
+ while (kept.at(-1) === "") {
365
+ kept.pop();
366
+ }
367
+ return { content: kept.join(newline), removed, newline };
368
+ }
369
+
370
+ function tomlString(value: string): string {
371
+ return JSON.stringify(value);
372
+ }
373
+
374
+ function serializeTomlEntry(entry: StandardMcpEntry, newline: string): string {
375
+ const args = entry.args.map(tomlString).join(", ");
376
+ const lines = [
377
+ "[mcp_servers.gno]",
378
+ `command = ${tomlString(entry.command)}`,
379
+ `args = [${args}]`,
380
+ ];
381
+ if (entry.env && Object.keys(entry.env).length > 0) {
382
+ lines.push("", "[mcp_servers.gno.env]");
383
+ for (const key of ["GNO_DATA_DIR", "GNO_CACHE_DIR"] as const) {
384
+ const value = entry.env[key];
385
+ if (value) {
386
+ lines.push(`${key} = ${tomlString(value)}`);
387
+ }
388
+ }
389
+ }
390
+ return `${lines.join(newline)}${newline}`;
391
+ }
392
+
393
+ export function setTomlServerEntry(
394
+ content: string,
395
+ configPath: string,
396
+ entry: StandardMcpEntry
397
+ ): string {
398
+ const parsed = getTomlServerEntry(content, configPath);
399
+ const removed = removeTomlGnoSections(content, configPath);
400
+ if (parsed.exists && !removed.removed) {
401
+ throw new CliError(
402
+ "RUNTIME",
403
+ `Unsupported inline GNO MCP entry in ${configPath}.`
404
+ );
405
+ }
406
+ const prefix = removed.content.trimEnd();
407
+ const updated = prefix
408
+ ? `${prefix}${removed.newline}${removed.newline}${serializeTomlEntry(entry, removed.newline)}`
409
+ : serializeTomlEntry(entry, removed.newline);
410
+ parseTomlRoot(updated, configPath);
411
+ return updated;
412
+ }
413
+
414
+ export function removeTomlServerEntry(
415
+ content: string,
416
+ configPath: string
417
+ ): { content: string; removed: boolean } {
418
+ const parsed = getTomlServerEntry(content, configPath);
419
+ if (!parsed.exists) {
420
+ return { content, removed: false };
421
+ }
422
+ const result = removeTomlGnoSections(content, configPath);
423
+ if (!result.removed) {
424
+ throw new CliError(
425
+ "RUNTIME",
426
+ `Unsupported inline GNO MCP entry in ${configPath}.`
427
+ );
428
+ }
429
+ const updated = result.content ? `${result.content}${result.newline}` : "";
430
+ parseTomlRoot(updated, configPath);
431
+ return { content: updated, removed: true };
432
+ }