@gmickel/gno 0.3.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 (131) hide show
  1. package/README.md +256 -0
  2. package/assets/skill/SKILL.md +112 -0
  3. package/assets/skill/cli-reference.md +327 -0
  4. package/assets/skill/examples.md +234 -0
  5. package/assets/skill/mcp-reference.md +159 -0
  6. package/package.json +90 -0
  7. package/src/app/constants.ts +313 -0
  8. package/src/cli/colors.ts +65 -0
  9. package/src/cli/commands/ask.ts +545 -0
  10. package/src/cli/commands/cleanup.ts +105 -0
  11. package/src/cli/commands/collection/add.ts +120 -0
  12. package/src/cli/commands/collection/index.ts +10 -0
  13. package/src/cli/commands/collection/list.ts +108 -0
  14. package/src/cli/commands/collection/remove.ts +64 -0
  15. package/src/cli/commands/collection/rename.ts +95 -0
  16. package/src/cli/commands/context/add.ts +67 -0
  17. package/src/cli/commands/context/check.ts +153 -0
  18. package/src/cli/commands/context/index.ts +10 -0
  19. package/src/cli/commands/context/list.ts +109 -0
  20. package/src/cli/commands/context/rm.ts +52 -0
  21. package/src/cli/commands/doctor.ts +393 -0
  22. package/src/cli/commands/embed.ts +462 -0
  23. package/src/cli/commands/get.ts +356 -0
  24. package/src/cli/commands/index-cmd.ts +119 -0
  25. package/src/cli/commands/index.ts +102 -0
  26. package/src/cli/commands/init.ts +328 -0
  27. package/src/cli/commands/ls.ts +217 -0
  28. package/src/cli/commands/mcp/config.ts +300 -0
  29. package/src/cli/commands/mcp/index.ts +24 -0
  30. package/src/cli/commands/mcp/install.ts +203 -0
  31. package/src/cli/commands/mcp/paths.ts +470 -0
  32. package/src/cli/commands/mcp/status.ts +222 -0
  33. package/src/cli/commands/mcp/uninstall.ts +158 -0
  34. package/src/cli/commands/mcp.ts +20 -0
  35. package/src/cli/commands/models/clear.ts +103 -0
  36. package/src/cli/commands/models/index.ts +32 -0
  37. package/src/cli/commands/models/list.ts +214 -0
  38. package/src/cli/commands/models/path.ts +51 -0
  39. package/src/cli/commands/models/pull.ts +199 -0
  40. package/src/cli/commands/models/use.ts +85 -0
  41. package/src/cli/commands/multi-get.ts +400 -0
  42. package/src/cli/commands/query.ts +220 -0
  43. package/src/cli/commands/ref-parser.ts +108 -0
  44. package/src/cli/commands/reset.ts +191 -0
  45. package/src/cli/commands/search.ts +136 -0
  46. package/src/cli/commands/shared.ts +156 -0
  47. package/src/cli/commands/skill/index.ts +19 -0
  48. package/src/cli/commands/skill/install.ts +197 -0
  49. package/src/cli/commands/skill/paths-cmd.ts +81 -0
  50. package/src/cli/commands/skill/paths.ts +191 -0
  51. package/src/cli/commands/skill/show.ts +73 -0
  52. package/src/cli/commands/skill/uninstall.ts +141 -0
  53. package/src/cli/commands/status.ts +205 -0
  54. package/src/cli/commands/update.ts +68 -0
  55. package/src/cli/commands/vsearch.ts +188 -0
  56. package/src/cli/context.ts +64 -0
  57. package/src/cli/errors.ts +64 -0
  58. package/src/cli/format/search-results.ts +211 -0
  59. package/src/cli/options.ts +183 -0
  60. package/src/cli/program.ts +1330 -0
  61. package/src/cli/run.ts +213 -0
  62. package/src/cli/ui.ts +92 -0
  63. package/src/config/defaults.ts +20 -0
  64. package/src/config/index.ts +55 -0
  65. package/src/config/loader.ts +161 -0
  66. package/src/config/paths.ts +87 -0
  67. package/src/config/saver.ts +153 -0
  68. package/src/config/types.ts +280 -0
  69. package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
  70. package/src/converters/adapters/officeparser/adapter.ts +126 -0
  71. package/src/converters/canonicalize.ts +89 -0
  72. package/src/converters/errors.ts +218 -0
  73. package/src/converters/index.ts +51 -0
  74. package/src/converters/mime.ts +163 -0
  75. package/src/converters/native/markdown.ts +115 -0
  76. package/src/converters/native/plaintext.ts +56 -0
  77. package/src/converters/path.ts +48 -0
  78. package/src/converters/pipeline.ts +159 -0
  79. package/src/converters/registry.ts +74 -0
  80. package/src/converters/types.ts +123 -0
  81. package/src/converters/versions.ts +24 -0
  82. package/src/index.ts +27 -0
  83. package/src/ingestion/chunker.ts +238 -0
  84. package/src/ingestion/index.ts +32 -0
  85. package/src/ingestion/language.ts +276 -0
  86. package/src/ingestion/sync.ts +671 -0
  87. package/src/ingestion/types.ts +219 -0
  88. package/src/ingestion/walker.ts +235 -0
  89. package/src/llm/cache.ts +467 -0
  90. package/src/llm/errors.ts +191 -0
  91. package/src/llm/index.ts +58 -0
  92. package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
  93. package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
  94. package/src/llm/nodeLlamaCpp/generation.ts +88 -0
  95. package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
  96. package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
  97. package/src/llm/registry.ts +86 -0
  98. package/src/llm/types.ts +129 -0
  99. package/src/mcp/resources/index.ts +151 -0
  100. package/src/mcp/server.ts +229 -0
  101. package/src/mcp/tools/get.ts +220 -0
  102. package/src/mcp/tools/index.ts +160 -0
  103. package/src/mcp/tools/multi-get.ts +263 -0
  104. package/src/mcp/tools/query.ts +226 -0
  105. package/src/mcp/tools/search.ts +119 -0
  106. package/src/mcp/tools/status.ts +81 -0
  107. package/src/mcp/tools/vsearch.ts +198 -0
  108. package/src/pipeline/chunk-lookup.ts +44 -0
  109. package/src/pipeline/expansion.ts +256 -0
  110. package/src/pipeline/explain.ts +115 -0
  111. package/src/pipeline/fusion.ts +185 -0
  112. package/src/pipeline/hybrid.ts +535 -0
  113. package/src/pipeline/index.ts +64 -0
  114. package/src/pipeline/query-language.ts +118 -0
  115. package/src/pipeline/rerank.ts +223 -0
  116. package/src/pipeline/search.ts +261 -0
  117. package/src/pipeline/types.ts +328 -0
  118. package/src/pipeline/vsearch.ts +348 -0
  119. package/src/store/index.ts +41 -0
  120. package/src/store/migrations/001-initial.ts +196 -0
  121. package/src/store/migrations/index.ts +20 -0
  122. package/src/store/migrations/runner.ts +187 -0
  123. package/src/store/sqlite/adapter.ts +1242 -0
  124. package/src/store/sqlite/index.ts +7 -0
  125. package/src/store/sqlite/setup.ts +129 -0
  126. package/src/store/sqlite/types.ts +28 -0
  127. package/src/store/types.ts +506 -0
  128. package/src/store/vector/index.ts +13 -0
  129. package/src/store/vector/sqlite-vec.ts +373 -0
  130. package/src/store/vector/stats.ts +152 -0
  131. package/src/store/vector/types.ts +115 -0
@@ -0,0 +1,1330 @@
1
+ /**
2
+ * Commander program definition.
3
+ * Wires all CLI commands with lazy imports for fast --help.
4
+ *
5
+ * @module src/cli/program
6
+ */
7
+
8
+ import { Command } from 'commander';
9
+ import {
10
+ CLI_NAME,
11
+ DOCS_URL,
12
+ ISSUES_URL,
13
+ PRODUCT_NAME,
14
+ VERSION,
15
+ } from '../app/constants';
16
+ import { setColorsEnabled } from './colors';
17
+ import {
18
+ applyGlobalOptions,
19
+ type GlobalOptions,
20
+ parseGlobalOptions,
21
+ } from './context';
22
+ import { CliError } from './errors';
23
+ import {
24
+ assertFormatSupported,
25
+ CMD,
26
+ getDefaultLimit,
27
+ parseOptionalFloat,
28
+ parsePositiveInt,
29
+ } from './options';
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Global State (set by preAction hook)
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ // Using object wrapper to allow mutation while satisfying linter
36
+ const globalState: { current: GlobalOptions | null } = { current: null };
37
+
38
+ /**
39
+ * Get resolved global options. Must be called after command parsing.
40
+ * Throws if called before preAction hook runs.
41
+ */
42
+ export function getGlobals(): GlobalOptions {
43
+ if (!globalState.current) {
44
+ throw new Error('Global options not resolved - called before preAction?');
45
+ }
46
+ return globalState.current;
47
+ }
48
+
49
+ /**
50
+ * Reset global state (for testing).
51
+ * Resets both option state and color state to avoid test pollution.
52
+ */
53
+ export function resetGlobals(): void {
54
+ globalState.current = null;
55
+ // Reset colors to default (true) - will be set by applyGlobalOptions on next run
56
+ setColorsEnabled(true);
57
+ }
58
+
59
+ /**
60
+ * Select output format with explicit precedence.
61
+ * Precedence: local non-json format > local --json > global --json > terminal
62
+ */
63
+ function getFormat(
64
+ cmdOpts: Record<string, unknown>
65
+ ): 'terminal' | 'json' | 'files' | 'csv' | 'md' | 'xml' {
66
+ const globals = getGlobals();
67
+
68
+ const local = {
69
+ json: Boolean(cmdOpts.json),
70
+ files: Boolean(cmdOpts.files),
71
+ csv: Boolean(cmdOpts.csv),
72
+ md: Boolean(cmdOpts.md),
73
+ xml: Boolean(cmdOpts.xml),
74
+ };
75
+
76
+ // Count local format flags
77
+ const localFormats = Object.entries(local).filter(([_, v]) => v);
78
+ if (localFormats.length > 1) {
79
+ throw new CliError(
80
+ 'VALIDATION',
81
+ `Conflicting output formats: ${localFormats.map(([k]) => k).join(', ')}. Choose one.`
82
+ );
83
+ }
84
+
85
+ // Local non-json format wins (--md, --csv, --files, --xml)
86
+ if (local.files) {
87
+ return 'files';
88
+ }
89
+ if (local.csv) {
90
+ return 'csv';
91
+ }
92
+ if (local.md) {
93
+ return 'md';
94
+ }
95
+ if (local.xml) {
96
+ return 'xml';
97
+ }
98
+
99
+ // Local --json wins over global
100
+ if (local.json) {
101
+ return 'json';
102
+ }
103
+
104
+ // Global --json as fallback
105
+ if (globals.json) {
106
+ return 'json';
107
+ }
108
+
109
+ return 'terminal';
110
+ }
111
+
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ // Program Factory
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+
116
+ export function createProgram(): Command {
117
+ const program = new Command();
118
+
119
+ program
120
+ .name(CLI_NAME)
121
+ .description(`${PRODUCT_NAME} - Local Knowledge Index and Retrieval`)
122
+ .version(VERSION, '-V, --version', 'show version')
123
+ .exitOverride() // Prevent Commander from calling process.exit()
124
+ .showSuggestionAfterError(true)
125
+ .showHelpAfterError('(Use --help for available options)');
126
+
127
+ // Global flags - resolved via preAction hook
128
+ program
129
+ .option('--index <name>', 'index name', 'default')
130
+ .option('--config <path>', 'config file path')
131
+ .option('--no-color', 'disable colors')
132
+ .option('--verbose', 'verbose logging')
133
+ .option('--yes', 'non-interactive mode')
134
+ .option('-q, --quiet', 'suppress non-essential output')
135
+ .option('--json', 'JSON output (for errors and supported commands)');
136
+
137
+ // Resolve globals ONCE before any command runs (ensures consistency)
138
+ program.hook('preAction', (thisCommand) => {
139
+ const rootOpts = thisCommand.optsWithGlobals();
140
+ const globals = parseGlobalOptions(rootOpts);
141
+ applyGlobalOptions(globals);
142
+ globalState.current = globals;
143
+ });
144
+
145
+ // Wire command groups
146
+ wireSearchCommands(program);
147
+ wireOnboardingCommands(program);
148
+ wireManagementCommands(program);
149
+ wireRetrievalCommands(program);
150
+ wireMcpCommand(program);
151
+ wireSkillCommands(program);
152
+
153
+ // Add docs/support links to help footer
154
+ program.addHelpText(
155
+ 'after',
156
+ `
157
+ Documentation: ${DOCS_URL}
158
+ Report issues: ${ISSUES_URL}`
159
+ );
160
+
161
+ return program;
162
+ }
163
+
164
+ // ─────────────────────────────────────────────────────────────────────────────
165
+ // Search Commands (search, vsearch, query, ask)
166
+ // ─────────────────────────────────────────────────────────────────────────────
167
+
168
+ function wireSearchCommands(program: Command): void {
169
+ // search - BM25 keyword search
170
+ program
171
+ .command('search <query>')
172
+ .description('BM25 keyword search')
173
+ .option('-n, --limit <num>', 'max results')
174
+ .option('--min-score <num>', 'minimum score threshold')
175
+ .option('-c, --collection <name>', 'filter by collection')
176
+ .option('--lang <code>', 'language filter/hint (BCP-47)')
177
+ .option('--full', 'include full content')
178
+ .option('--line-numbers', 'include line numbers in output')
179
+ .option('--json', 'JSON output')
180
+ .option('--md', 'Markdown output')
181
+ .option('--csv', 'CSV output')
182
+ .option('--xml', 'XML output')
183
+ .option('--files', 'file paths only')
184
+ .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
185
+ const format = getFormat(cmdOpts);
186
+ assertFormatSupported(CMD.search, format);
187
+
188
+ // Validate empty query
189
+ if (!queryText.trim()) {
190
+ throw new CliError('VALIDATION', 'Query cannot be empty');
191
+ }
192
+
193
+ // Validate minScore range
194
+ const minScore = parseOptionalFloat('min-score', cmdOpts.minScore);
195
+ if (minScore !== undefined && (minScore < 0 || minScore > 1)) {
196
+ throw new CliError('VALIDATION', '--min-score must be between 0 and 1');
197
+ }
198
+
199
+ const limit = cmdOpts.limit
200
+ ? parsePositiveInt('limit', cmdOpts.limit)
201
+ : getDefaultLimit(format);
202
+
203
+ const { search, formatSearch } = await import('./commands/search');
204
+ const result = await search(queryText, {
205
+ limit,
206
+ minScore,
207
+ collection: cmdOpts.collection as string | undefined,
208
+ lang: cmdOpts.lang as string | undefined,
209
+ full: Boolean(cmdOpts.full),
210
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
211
+ json: format === 'json',
212
+ md: format === 'md',
213
+ csv: format === 'csv',
214
+ xml: format === 'xml',
215
+ files: format === 'files',
216
+ });
217
+
218
+ // Check success before printing - stdout is for successful outputs only
219
+ if (!result.success) {
220
+ // Map validation errors to exit code 1
221
+ throw new CliError(
222
+ result.isValidation ? 'VALIDATION' : 'RUNTIME',
223
+ result.error
224
+ );
225
+ }
226
+ process.stdout.write(
227
+ `${formatSearch(result, {
228
+ json: format === 'json',
229
+ md: format === 'md',
230
+ csv: format === 'csv',
231
+ xml: format === 'xml',
232
+ files: format === 'files',
233
+ full: Boolean(cmdOpts.full),
234
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
235
+ })}\n`
236
+ );
237
+ });
238
+
239
+ // vsearch - Vector similarity search
240
+ program
241
+ .command('vsearch <query>')
242
+ .description('Vector similarity search')
243
+ .option('-n, --limit <num>', 'max results')
244
+ .option('--min-score <num>', 'minimum score threshold')
245
+ .option('-c, --collection <name>', 'filter by collection')
246
+ .option('--lang <code>', 'language filter/hint (BCP-47)')
247
+ .option('--full', 'include full content')
248
+ .option('--line-numbers', 'include line numbers in output')
249
+ .option('--json', 'JSON output')
250
+ .option('--md', 'Markdown output')
251
+ .option('--csv', 'CSV output')
252
+ .option('--xml', 'XML output')
253
+ .option('--files', 'file paths only')
254
+ .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
255
+ const format = getFormat(cmdOpts);
256
+ assertFormatSupported(CMD.vsearch, format);
257
+
258
+ // Validate empty query
259
+ if (!queryText.trim()) {
260
+ throw new CliError('VALIDATION', 'Query cannot be empty');
261
+ }
262
+
263
+ // Validate minScore range
264
+ const minScore = parseOptionalFloat('min-score', cmdOpts.minScore);
265
+ if (minScore !== undefined && (minScore < 0 || minScore > 1)) {
266
+ throw new CliError('VALIDATION', '--min-score must be between 0 and 1');
267
+ }
268
+
269
+ const limit = cmdOpts.limit
270
+ ? parsePositiveInt('limit', cmdOpts.limit)
271
+ : getDefaultLimit(format);
272
+
273
+ const { vsearch, formatVsearch } = await import('./commands/vsearch');
274
+ const result = await vsearch(queryText, {
275
+ limit,
276
+ minScore,
277
+ collection: cmdOpts.collection as string | undefined,
278
+ lang: cmdOpts.lang as string | undefined,
279
+ full: Boolean(cmdOpts.full),
280
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
281
+ json: format === 'json',
282
+ md: format === 'md',
283
+ csv: format === 'csv',
284
+ xml: format === 'xml',
285
+ files: format === 'files',
286
+ });
287
+
288
+ if (!result.success) {
289
+ throw new CliError('RUNTIME', result.error);
290
+ }
291
+ process.stdout.write(
292
+ `${formatVsearch(result, {
293
+ json: format === 'json',
294
+ md: format === 'md',
295
+ csv: format === 'csv',
296
+ xml: format === 'xml',
297
+ files: format === 'files',
298
+ full: Boolean(cmdOpts.full),
299
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
300
+ })}\n`
301
+ );
302
+ });
303
+
304
+ // query - Hybrid search with expansion and reranking
305
+ program
306
+ .command('query <query>')
307
+ .description('Hybrid search with expansion and reranking')
308
+ .option('-n, --limit <num>', 'max results')
309
+ .option('--min-score <num>', 'minimum score threshold')
310
+ .option('-c, --collection <name>', 'filter by collection')
311
+ .option('--lang <code>', 'language hint (BCP-47)')
312
+ .option('--full', 'include full content')
313
+ .option('--line-numbers', 'include line numbers in output')
314
+ .option('--no-expand', 'disable query expansion')
315
+ .option('--no-rerank', 'disable reranking')
316
+ .option('--explain', 'include scoring explanation')
317
+ .option('--json', 'JSON output')
318
+ .option('--md', 'Markdown output')
319
+ .option('--csv', 'CSV output')
320
+ .option('--xml', 'XML output')
321
+ .option('--files', 'file paths only')
322
+ .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
323
+ const format = getFormat(cmdOpts);
324
+ assertFormatSupported(CMD.query, format);
325
+
326
+ // Validate empty query
327
+ if (!queryText.trim()) {
328
+ throw new CliError('VALIDATION', 'Query cannot be empty');
329
+ }
330
+
331
+ // Validate minScore range
332
+ const minScore = parseOptionalFloat('min-score', cmdOpts.minScore);
333
+ if (minScore !== undefined && (minScore < 0 || minScore > 1)) {
334
+ throw new CliError('VALIDATION', '--min-score must be between 0 and 1');
335
+ }
336
+
337
+ const limit = cmdOpts.limit
338
+ ? parsePositiveInt('limit', cmdOpts.limit)
339
+ : getDefaultLimit(format);
340
+
341
+ const { query, formatQuery } = await import('./commands/query');
342
+ const result = await query(queryText, {
343
+ limit,
344
+ minScore,
345
+ collection: cmdOpts.collection as string | undefined,
346
+ lang: cmdOpts.lang as string | undefined,
347
+ full: Boolean(cmdOpts.full),
348
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
349
+ noExpand: cmdOpts.expand === false,
350
+ noRerank: cmdOpts.rerank === false,
351
+ explain: Boolean(cmdOpts.explain),
352
+ json: format === 'json',
353
+ md: format === 'md',
354
+ csv: format === 'csv',
355
+ xml: format === 'xml',
356
+ files: format === 'files',
357
+ });
358
+
359
+ if (!result.success) {
360
+ throw new CliError('RUNTIME', result.error);
361
+ }
362
+ process.stdout.write(
363
+ `${formatQuery(result, {
364
+ format,
365
+ full: Boolean(cmdOpts.full),
366
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
367
+ })}\n`
368
+ );
369
+ });
370
+
371
+ // ask - Human-friendly query with grounded answer
372
+ program
373
+ .command('ask <query>')
374
+ .description('Human-friendly query with grounded answer')
375
+ .option('-n, --limit <num>', 'max source results')
376
+ .option('-c, --collection <name>', 'filter by collection')
377
+ .option('--lang <code>', 'language hint (BCP-47)')
378
+ .option('--answer', 'generate short grounded answer')
379
+ .option('--no-answer', 'force retrieval-only output')
380
+ .option('--max-answer-tokens <num>', 'max answer tokens')
381
+ .option('--show-sources', 'show all retrieved sources (not just cited)')
382
+ .option('--json', 'JSON output')
383
+ .option('--md', 'Markdown output')
384
+ .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
385
+ const format = getFormat(cmdOpts);
386
+ assertFormatSupported(CMD.ask, format);
387
+
388
+ // Validate empty query
389
+ if (!queryText.trim()) {
390
+ throw new CliError('VALIDATION', 'Query cannot be empty');
391
+ }
392
+
393
+ const limit = cmdOpts.limit
394
+ ? parsePositiveInt('limit', cmdOpts.limit)
395
+ : getDefaultLimit(format);
396
+
397
+ // Parse max-answer-tokens (optional, defaults to 512 in command impl)
398
+ const maxAnswerTokens = cmdOpts.maxAnswerTokens
399
+ ? parsePositiveInt('max-answer-tokens', cmdOpts.maxAnswerTokens)
400
+ : undefined;
401
+
402
+ const { ask, formatAsk } = await import('./commands/ask');
403
+ const showSources = Boolean(cmdOpts.showSources);
404
+ const result = await ask(queryText, {
405
+ limit,
406
+ collection: cmdOpts.collection as string | undefined,
407
+ lang: cmdOpts.lang as string | undefined,
408
+ // Per spec: --answer defaults to false, --no-answer forces retrieval-only
409
+ // Commander creates separate cmdOpts.noAnswer for --no-answer flag
410
+ answer: Boolean(cmdOpts.answer),
411
+ noAnswer: Boolean(cmdOpts.noAnswer),
412
+ maxAnswerTokens,
413
+ showSources,
414
+ json: format === 'json',
415
+ md: format === 'md',
416
+ });
417
+
418
+ if (!result.success) {
419
+ throw new CliError('RUNTIME', result.error);
420
+ }
421
+ process.stdout.write(
422
+ `${formatAsk(result, { json: format === 'json', md: format === 'md', showSources })}\n`
423
+ );
424
+ });
425
+ }
426
+
427
+ // ─────────────────────────────────────────────────────────────────────────────
428
+ // Onboarding Commands (init, index, status, doctor)
429
+ // ─────────────────────────────────────────────────────────────────────────────
430
+
431
+ function wireOnboardingCommands(program: Command): void {
432
+ // init - Initialize GNO
433
+ program
434
+ .command('init [path]')
435
+ .description('Initialize GNO configuration')
436
+ .option('-n, --name <name>', 'collection name')
437
+ .option('--pattern <glob>', 'file matching pattern')
438
+ .option('--include <exts>', 'extension allowlist (CSV)')
439
+ .option('--exclude <patterns>', 'exclude patterns (CSV)')
440
+ .option('--update <cmd>', 'shell command to run before indexing')
441
+ .option('--tokenizer <type>', 'FTS tokenizer (unicode61, porter, trigram)')
442
+ .option('--language <code>', 'language hint (BCP-47)')
443
+ .action(
444
+ async (path: string | undefined, cmdOpts: Record<string, unknown>) => {
445
+ const globals = getGlobals();
446
+ const { init } = await import('./commands/init');
447
+ const result = await init({
448
+ path,
449
+ name: cmdOpts.name as string | undefined,
450
+ pattern: cmdOpts.pattern as string | undefined,
451
+ include: cmdOpts.include as string | undefined,
452
+ exclude: cmdOpts.exclude as string | undefined,
453
+ update: cmdOpts.update as string | undefined,
454
+ tokenizer: cmdOpts.tokenizer as
455
+ | 'unicode61'
456
+ | 'porter'
457
+ | 'trigram'
458
+ | undefined,
459
+ language: cmdOpts.language as string | undefined,
460
+ yes: globals.yes,
461
+ });
462
+
463
+ if (!result.success) {
464
+ throw new CliError('RUNTIME', result.error ?? 'Init failed');
465
+ }
466
+
467
+ if (result.alreadyInitialized) {
468
+ process.stdout.write('GNO already initialized.\n');
469
+ if (result.collectionAdded) {
470
+ process.stdout.write(
471
+ `Collection "${result.collectionAdded}" added.\n`
472
+ );
473
+ }
474
+ } else {
475
+ process.stdout.write('GNO initialized successfully.\n');
476
+ process.stdout.write(`Config: ${result.configPath}\n`);
477
+ process.stdout.write(`Database: ${result.dbPath}\n`);
478
+ if (result.collectionAdded) {
479
+ process.stdout.write(
480
+ `Collection "${result.collectionAdded}" added.\n`
481
+ );
482
+ }
483
+ }
484
+ }
485
+ );
486
+
487
+ // index - Index collections
488
+ program
489
+ .command('index [collection]')
490
+ .description('Index files from collections')
491
+ .option('--no-embed', 'skip embedding after sync')
492
+ .option('--git-pull', 'run git pull in git repositories')
493
+ .option('--models-pull', 'download models if missing')
494
+ .action(
495
+ async (
496
+ collection: string | undefined,
497
+ cmdOpts: Record<string, unknown>
498
+ ) => {
499
+ const globals = getGlobals();
500
+ const { index, formatIndex } = await import('./commands/index-cmd');
501
+ const opts = {
502
+ collection,
503
+ noEmbed: cmdOpts.embed === false,
504
+ gitPull: Boolean(cmdOpts.gitPull),
505
+ modelsPull: Boolean(cmdOpts.modelsPull),
506
+ yes: globals.yes,
507
+ verbose: globals.verbose,
508
+ };
509
+ const result = await index(opts);
510
+
511
+ if (!result.success) {
512
+ throw new CliError('RUNTIME', result.error ?? 'Index failed');
513
+ }
514
+ process.stdout.write(`${formatIndex(result, opts)}\n`);
515
+ }
516
+ );
517
+
518
+ // status - Show index status
519
+ program
520
+ .command('status')
521
+ .description('Show index status')
522
+ .option('--json', 'JSON output')
523
+ .action(async (cmdOpts: Record<string, unknown>) => {
524
+ const format = getFormat(cmdOpts);
525
+ assertFormatSupported(CMD.status, format);
526
+
527
+ const { status, formatStatus } = await import('./commands/status');
528
+ const result = await status({ json: format === 'json' });
529
+
530
+ if (!result.success) {
531
+ throw new CliError('RUNTIME', result.error ?? 'Status failed');
532
+ }
533
+ process.stdout.write(
534
+ `${formatStatus(result, { json: format === 'json' })}\n`
535
+ );
536
+ });
537
+
538
+ // doctor - Diagnose configuration issues
539
+ program
540
+ .command('doctor')
541
+ .description('Diagnose configuration issues')
542
+ .option('--json', 'JSON output')
543
+ .action(async (cmdOpts: Record<string, unknown>) => {
544
+ const format = getFormat(cmdOpts);
545
+ const { doctor, formatDoctor } = await import('./commands/doctor');
546
+ const result = await doctor({ json: format === 'json' });
547
+
548
+ // Doctor always succeeds but may report issues
549
+ process.stdout.write(
550
+ `${formatDoctor(result, { json: format === 'json' })}\n`
551
+ );
552
+ });
553
+ }
554
+
555
+ // ─────────────────────────────────────────────────────────────────────────────
556
+ // Retrieval Commands (get, multi-get, ls)
557
+ // ─────────────────────────────────────────────────────────────────────────────
558
+
559
+ function wireRetrievalCommands(program: Command): void {
560
+ // get - Retrieve document by URI or docid
561
+ program
562
+ .command('get <ref>')
563
+ .description('Get document by URI or docid')
564
+ .option(
565
+ '--from <line>',
566
+ 'Start at line number',
567
+ parsePositiveInt.bind(null, 'from')
568
+ )
569
+ .option(
570
+ '-l, --limit <lines>',
571
+ 'Limit to N lines',
572
+ parsePositiveInt.bind(null, 'limit')
573
+ )
574
+ .option('--line-numbers', 'Prefix lines with numbers')
575
+ .option('--source', 'Include source metadata')
576
+ .option('--json', 'JSON output')
577
+ .option('--md', 'Markdown output')
578
+ .action(async (ref: string, cmdOpts: Record<string, unknown>) => {
579
+ const format = getFormat(cmdOpts);
580
+ assertFormatSupported(CMD.get, format);
581
+ const globals = getGlobals();
582
+
583
+ const { get, formatGet } = await import('./commands/get');
584
+ const result = await get(ref, {
585
+ configPath: globals.config,
586
+ from: cmdOpts.from as number | undefined,
587
+ limit: cmdOpts.limit as number | undefined,
588
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
589
+ source: Boolean(cmdOpts.source),
590
+ json: format === 'json',
591
+ md: format === 'md',
592
+ });
593
+
594
+ if (!result.success) {
595
+ throw new CliError(
596
+ result.isValidation ? 'VALIDATION' : 'RUNTIME',
597
+ result.error
598
+ );
599
+ }
600
+
601
+ process.stdout.write(
602
+ `${formatGet(result, {
603
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
604
+ json: format === 'json',
605
+ md: format === 'md',
606
+ })}\n`
607
+ );
608
+ });
609
+
610
+ // multi-get - Retrieve multiple documents
611
+ program
612
+ .command('multi-get <refs...>')
613
+ .description('Get multiple documents by URI or docid')
614
+ .option(
615
+ '--max-bytes <n>',
616
+ 'Max bytes per document',
617
+ parsePositiveInt.bind(null, 'max-bytes')
618
+ )
619
+ .option('--line-numbers', 'Include line numbers')
620
+ .option('--json', 'JSON output')
621
+ .option('--files', 'File protocol output')
622
+ .option('--md', 'Markdown output')
623
+ .action(async (refs: string[], cmdOpts: Record<string, unknown>) => {
624
+ const format = getFormat(cmdOpts);
625
+ assertFormatSupported(CMD.multiGet, format);
626
+ const globals = getGlobals();
627
+
628
+ const { multiGet, formatMultiGet } = await import('./commands/multi-get');
629
+ const result = await multiGet(refs, {
630
+ configPath: globals.config,
631
+ maxBytes: cmdOpts.maxBytes as number | undefined,
632
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
633
+ json: format === 'json',
634
+ files: format === 'files',
635
+ md: format === 'md',
636
+ });
637
+
638
+ if (!result.success) {
639
+ throw new CliError(
640
+ result.isValidation ? 'VALIDATION' : 'RUNTIME',
641
+ result.error
642
+ );
643
+ }
644
+
645
+ process.stdout.write(
646
+ `${formatMultiGet(result, {
647
+ lineNumbers: Boolean(cmdOpts.lineNumbers),
648
+ json: format === 'json',
649
+ files: format === 'files',
650
+ md: format === 'md',
651
+ })}\n`
652
+ );
653
+ });
654
+
655
+ // ls - List indexed documents
656
+ program
657
+ .command('ls [scope]')
658
+ .description('List indexed documents')
659
+ .option(
660
+ '-n, --limit <num>',
661
+ 'Max results',
662
+ parsePositiveInt.bind(null, 'limit')
663
+ )
664
+ .option(
665
+ '--offset <num>',
666
+ 'Skip first N results',
667
+ parsePositiveInt.bind(null, 'offset')
668
+ )
669
+ .option('--json', 'JSON output')
670
+ .option('--files', 'File protocol output')
671
+ .option('--md', 'Markdown output')
672
+ .action(
673
+ async (scope: string | undefined, cmdOpts: Record<string, unknown>) => {
674
+ const format = getFormat(cmdOpts);
675
+ assertFormatSupported(CMD.ls, format);
676
+ const globals = getGlobals();
677
+
678
+ const { ls, formatLs } = await import('./commands/ls');
679
+ const result = await ls(scope, {
680
+ configPath: globals.config,
681
+ limit: cmdOpts.limit as number | undefined,
682
+ offset: cmdOpts.offset as number | undefined,
683
+ json: format === 'json',
684
+ files: format === 'files',
685
+ md: format === 'md',
686
+ });
687
+
688
+ if (!result.success) {
689
+ throw new CliError(
690
+ result.isValidation ? 'VALIDATION' : 'RUNTIME',
691
+ result.error
692
+ );
693
+ }
694
+
695
+ process.stdout.write(
696
+ `${formatLs(result, {
697
+ json: format === 'json',
698
+ files: format === 'files',
699
+ md: format === 'md',
700
+ })}\n`
701
+ );
702
+ }
703
+ );
704
+ }
705
+
706
+ // ─────────────────────────────────────────────────────────────────────────────
707
+ // MCP Commands
708
+ // ─────────────────────────────────────────────────────────────────────────────
709
+
710
+ function wireMcpCommand(program: Command): void {
711
+ // mcp - Start MCP server (stdio transport) or manage MCP configuration
712
+ // CRITICAL: helpOption(false) on server command prevents --help from writing
713
+ // to stdout which would corrupt the JSON-RPC stream
714
+ const mcpCmd = program
715
+ .command('mcp')
716
+ .description('MCP server and configuration');
717
+
718
+ // Default action: start MCP server
719
+ mcpCmd
720
+ .command('serve', { isDefault: true })
721
+ .description('Start MCP server (stdio transport)')
722
+ .helpOption(false)
723
+ .action(async () => {
724
+ const { mcpCommand } = await import('./commands/mcp.js');
725
+ const globalOpts = program.opts();
726
+ const globals = parseGlobalOptions(globalOpts);
727
+ await mcpCommand(globals);
728
+ });
729
+
730
+ // install - Install gno MCP server to client configs
731
+ mcpCmd
732
+ .command('install')
733
+ .description('Install gno as MCP server in client configuration')
734
+ .option(
735
+ '-t, --target <target>',
736
+ 'target client (claude-desktop, cursor, zed, windsurf, opencode, amp, lmstudio, librechat, claude-code, codex)',
737
+ 'claude-desktop'
738
+ )
739
+ .option(
740
+ '-s, --scope <scope>',
741
+ 'scope (user, project) - project only for claude-code/codex/cursor/opencode',
742
+ 'user'
743
+ )
744
+ .option('-f, --force', 'overwrite existing configuration')
745
+ .option('--dry-run', 'show what would be done without making changes')
746
+ .option('--json', 'JSON output')
747
+ .action(async (cmdOpts: Record<string, unknown>) => {
748
+ const target = cmdOpts.target as string;
749
+ const scope = cmdOpts.scope as string;
750
+
751
+ // Import MCP_TARGETS for validation
752
+ const { MCP_TARGETS } = await import('./commands/mcp/paths.js');
753
+
754
+ // Validate target
755
+ if (!(MCP_TARGETS as string[]).includes(target)) {
756
+ throw new CliError(
757
+ 'VALIDATION',
758
+ `Invalid target: ${target}. Must be one of: ${MCP_TARGETS.join(', ')}.`
759
+ );
760
+ }
761
+ // Validate scope
762
+ if (!['user', 'project'].includes(scope)) {
763
+ throw new CliError(
764
+ 'VALIDATION',
765
+ `Invalid scope: ${scope}. Must be 'user' or 'project'.`
766
+ );
767
+ }
768
+
769
+ const { installMcp } = await import('./commands/mcp/install.js');
770
+ await installMcp({
771
+ target: target as NonNullable<
772
+ Parameters<typeof installMcp>[0]
773
+ >['target'],
774
+ scope: scope as 'user' | 'project',
775
+ force: Boolean(cmdOpts.force),
776
+ dryRun: Boolean(cmdOpts.dryRun),
777
+ // Pass undefined if not set, so global --json can take effect
778
+ json: cmdOpts.json === true ? true : undefined,
779
+ });
780
+ });
781
+
782
+ // uninstall - Remove gno MCP server from client configs
783
+ mcpCmd
784
+ .command('uninstall')
785
+ .description('Remove gno MCP server from client configuration')
786
+ .option(
787
+ '-t, --target <target>',
788
+ 'target client (claude-desktop, cursor, zed, windsurf, opencode, amp, lmstudio, librechat, claude-code, codex)',
789
+ 'claude-desktop'
790
+ )
791
+ .option('-s, --scope <scope>', 'scope (user, project)', 'user')
792
+ .option('--json', 'JSON output')
793
+ .action(async (cmdOpts: Record<string, unknown>) => {
794
+ const target = cmdOpts.target as string;
795
+ const scope = cmdOpts.scope as string;
796
+
797
+ // Import MCP_TARGETS for validation
798
+ const { MCP_TARGETS } = await import('./commands/mcp/paths.js');
799
+
800
+ // Validate target
801
+ if (!(MCP_TARGETS as string[]).includes(target)) {
802
+ throw new CliError(
803
+ 'VALIDATION',
804
+ `Invalid target: ${target}. Must be one of: ${MCP_TARGETS.join(', ')}.`
805
+ );
806
+ }
807
+ // Validate scope
808
+ if (!['user', 'project'].includes(scope)) {
809
+ throw new CliError(
810
+ 'VALIDATION',
811
+ `Invalid scope: ${scope}. Must be 'user' or 'project'.`
812
+ );
813
+ }
814
+
815
+ const { uninstallMcp } = await import('./commands/mcp/uninstall.js');
816
+ await uninstallMcp({
817
+ target: target as NonNullable<
818
+ Parameters<typeof uninstallMcp>[0]
819
+ >['target'],
820
+ scope: scope as 'user' | 'project',
821
+ // Pass undefined if not set, so global --json can take effect
822
+ json: cmdOpts.json === true ? true : undefined,
823
+ });
824
+ });
825
+
826
+ // status - Show MCP installation status
827
+ mcpCmd
828
+ .command('status')
829
+ .description('Show MCP server installation status')
830
+ .option(
831
+ '-t, --target <target>',
832
+ 'filter by target (claude-desktop, cursor, zed, windsurf, opencode, amp, lmstudio, librechat, claude-code, codex, all)',
833
+ 'all'
834
+ )
835
+ .option(
836
+ '-s, --scope <scope>',
837
+ 'filter by scope (user, project, all)',
838
+ 'all'
839
+ )
840
+ .option('--json', 'JSON output')
841
+ .action(async (cmdOpts: Record<string, unknown>) => {
842
+ const target = cmdOpts.target as string;
843
+ const scope = cmdOpts.scope as string;
844
+
845
+ // Import MCP_TARGETS for validation
846
+ const { MCP_TARGETS, TARGETS_WITH_PROJECT_SCOPE } = await import(
847
+ './commands/mcp/paths.js'
848
+ );
849
+
850
+ // Validate target
851
+ if (target !== 'all' && !(MCP_TARGETS as string[]).includes(target)) {
852
+ throw new CliError(
853
+ 'VALIDATION',
854
+ `Invalid target: ${target}. Must be one of: ${MCP_TARGETS.join(', ')}, all.`
855
+ );
856
+ }
857
+ // Validate scope
858
+ if (!['user', 'project', 'all'].includes(scope)) {
859
+ throw new CliError(
860
+ 'VALIDATION',
861
+ `Invalid scope: ${scope}. Must be 'user', 'project', or 'all'.`
862
+ );
863
+ }
864
+ // Validate target/scope combination
865
+ if (
866
+ target !== 'all' &&
867
+ scope === 'project' &&
868
+ !(TARGETS_WITH_PROJECT_SCOPE as string[]).includes(target)
869
+ ) {
870
+ throw new CliError(
871
+ 'VALIDATION',
872
+ `${target} does not support project scope.`
873
+ );
874
+ }
875
+
876
+ const { statusMcp } = await import('./commands/mcp/status.js');
877
+ await statusMcp({
878
+ target: target as NonNullable<
879
+ Parameters<typeof statusMcp>[0]
880
+ >['target'],
881
+ scope: scope as 'user' | 'project' | 'all',
882
+ // Pass undefined if not set, so global --json can take effect
883
+ json: cmdOpts.json === true ? true : undefined,
884
+ });
885
+ });
886
+ }
887
+
888
+ // ─────────────────────────────────────────────────────────────────────────────
889
+ // Management Commands (collection, context, models, update, embed, cleanup)
890
+ // ─────────────────────────────────────────────────────────────────────────────
891
+
892
+ function wireManagementCommands(program: Command): void {
893
+ // collection subcommands
894
+ const collectionCmd = program
895
+ .command('collection')
896
+ .description('Manage collections');
897
+
898
+ collectionCmd
899
+ .command('add <path>')
900
+ .description('Add a collection')
901
+ .requiredOption('-n, --name <name>', 'collection name')
902
+ .option('--pattern <glob>', 'file matching pattern')
903
+ .option('--include <exts>', 'extension allowlist (CSV)')
904
+ .option('--exclude <patterns>', 'exclude patterns (CSV)')
905
+ .option('--update <cmd>', 'shell command to run before indexing')
906
+ .action(async (path: string, cmdOpts: Record<string, unknown>) => {
907
+ const { collectionAdd } = await import('./commands/collection');
908
+ await collectionAdd(path, {
909
+ name: cmdOpts.name as string,
910
+ pattern: cmdOpts.pattern as string | undefined,
911
+ include: cmdOpts.include as string | undefined,
912
+ exclude: cmdOpts.exclude as string | undefined,
913
+ update: cmdOpts.update as string | undefined,
914
+ });
915
+ });
916
+
917
+ collectionCmd
918
+ .command('list')
919
+ .description('List collections')
920
+ .option('--json', 'JSON output')
921
+ .option('--md', 'Markdown output')
922
+ .action(async (cmdOpts: Record<string, unknown>) => {
923
+ const format = getFormat(cmdOpts);
924
+ assertFormatSupported(CMD.collectionList, format);
925
+
926
+ const { collectionList } = await import('./commands/collection');
927
+ await collectionList({
928
+ json: format === 'json',
929
+ md: format === 'md',
930
+ });
931
+ });
932
+
933
+ collectionCmd
934
+ .command('remove <name>')
935
+ .description('Remove a collection')
936
+ .action(async (name: string) => {
937
+ const { collectionRemove } = await import('./commands/collection');
938
+ await collectionRemove(name);
939
+ });
940
+
941
+ collectionCmd
942
+ .command('rename <old> <new>')
943
+ .description('Rename a collection')
944
+ .action(async (oldName: string, newName: string) => {
945
+ const { collectionRename } = await import('./commands/collection');
946
+ await collectionRename(oldName, newName);
947
+ });
948
+
949
+ // context subcommands
950
+ const contextCmd = program
951
+ .command('context')
952
+ .description('Manage context items');
953
+
954
+ contextCmd
955
+ .command('add <scope> <text>')
956
+ .description('Add context metadata for a scope')
957
+ .action(async (scope: string, text: string) => {
958
+ const { contextAdd } = await import('./commands/context');
959
+ const exitCode = await contextAdd(scope, text);
960
+ if (exitCode !== 0) {
961
+ throw new CliError('RUNTIME', 'Failed to add context');
962
+ }
963
+ });
964
+
965
+ contextCmd
966
+ .command('list')
967
+ .description('List context items')
968
+ .option('--json', 'JSON output')
969
+ .option('--md', 'Markdown output')
970
+ .action(async (cmdOpts: Record<string, unknown>) => {
971
+ const format = getFormat(cmdOpts);
972
+ assertFormatSupported(CMD.contextList, format);
973
+
974
+ const { contextList } = await import('./commands/context');
975
+ await contextList(format as 'terminal' | 'json' | 'md');
976
+ });
977
+
978
+ contextCmd
979
+ .command('check')
980
+ .description('Check context configuration')
981
+ .option('--json', 'JSON output')
982
+ .option('--md', 'Markdown output')
983
+ .action(async (cmdOpts: Record<string, unknown>) => {
984
+ const format = getFormat(cmdOpts);
985
+ assertFormatSupported(CMD.contextCheck, format);
986
+
987
+ const { contextCheck } = await import('./commands/context');
988
+ await contextCheck(format as 'terminal' | 'json' | 'md');
989
+ });
990
+
991
+ contextCmd
992
+ .command('rm <uri>')
993
+ .description('Remove context item')
994
+ .action(async (uri: string) => {
995
+ const { contextRm } = await import('./commands/context');
996
+ await contextRm(uri);
997
+ });
998
+
999
+ // models subcommands
1000
+ const modelsCmd = program.command('models').description('Manage LLM models');
1001
+
1002
+ modelsCmd
1003
+ .command('list')
1004
+ .description('List available models')
1005
+ .option('--json', 'JSON output')
1006
+ .action(async (cmdOpts: Record<string, unknown>) => {
1007
+ const format = getFormat(cmdOpts);
1008
+ assertFormatSupported(CMD.modelsList, format);
1009
+
1010
+ const { modelsList, formatModelsList } = await import(
1011
+ './commands/models'
1012
+ );
1013
+ const result = await modelsList({ json: format === 'json' });
1014
+ process.stdout.write(
1015
+ `${formatModelsList(result, { json: format === 'json' })}\n`
1016
+ );
1017
+ });
1018
+
1019
+ modelsCmd
1020
+ .command('use')
1021
+ .description('Switch active model preset')
1022
+ .argument('<preset>', 'preset ID (slim, balanced, quality)')
1023
+ .action(async (preset: string) => {
1024
+ const globals = getGlobals();
1025
+ const { modelsUse, formatModelsUse } = await import(
1026
+ './commands/models/use'
1027
+ );
1028
+ const result = await modelsUse(preset, { configPath: globals.config });
1029
+ if (!result.success) {
1030
+ throw new CliError('VALIDATION', result.error);
1031
+ }
1032
+ process.stdout.write(`${formatModelsUse(result)}\n`);
1033
+ });
1034
+
1035
+ modelsCmd
1036
+ .command('pull')
1037
+ .description('Download models')
1038
+ .option('--all', 'download all configured models')
1039
+ .option('--embed', 'download embedding model')
1040
+ .option('--rerank', 'download reranker model')
1041
+ .option('--gen', 'download generation model')
1042
+ .option('--force', 'force re-download')
1043
+ .option('--no-progress', 'disable download progress')
1044
+ .action(async (cmdOpts: Record<string, unknown>) => {
1045
+ const globals = getGlobals();
1046
+ const { modelsPull, formatModelsPull, createProgressRenderer } =
1047
+ await import('./commands/models');
1048
+
1049
+ // Merge global quiet/json with local --no-progress
1050
+ const showProgress =
1051
+ (process.stderr.isTTY ?? false) &&
1052
+ !globals.quiet &&
1053
+ !globals.json &&
1054
+ cmdOpts.progress !== false;
1055
+
1056
+ const result = await modelsPull({
1057
+ all: Boolean(cmdOpts.all),
1058
+ embed: Boolean(cmdOpts.embed),
1059
+ rerank: Boolean(cmdOpts.rerank),
1060
+ gen: Boolean(cmdOpts.gen),
1061
+ force: Boolean(cmdOpts.force),
1062
+ onProgress: showProgress ? createProgressRenderer() : undefined,
1063
+ });
1064
+
1065
+ // For models pull, print result first, then check for failures
1066
+ // This allows partial success output before throwing
1067
+ process.stdout.write(`${formatModelsPull(result)}\n`);
1068
+ if (result.failed > 0) {
1069
+ throw new CliError('RUNTIME', `${result.failed} model(s) failed`);
1070
+ }
1071
+ });
1072
+
1073
+ modelsCmd
1074
+ .command('clear')
1075
+ .description('Clear model cache')
1076
+ .action(async () => {
1077
+ const globals = getGlobals();
1078
+ const { modelsClear, formatModelsClear } = await import(
1079
+ './commands/models'
1080
+ );
1081
+ const result = await modelsClear({ yes: globals.yes });
1082
+ process.stdout.write(`${formatModelsClear(result)}\n`);
1083
+ });
1084
+
1085
+ modelsCmd
1086
+ .command('path')
1087
+ .description('Show model cache path')
1088
+ .option('--json', 'JSON output')
1089
+ .action(async (cmdOpts: Record<string, unknown>) => {
1090
+ const format = getFormat(cmdOpts);
1091
+ const { modelsPath, formatModelsPath } = await import(
1092
+ './commands/models'
1093
+ );
1094
+ const result = modelsPath();
1095
+ process.stdout.write(
1096
+ `${formatModelsPath(result, { json: format === 'json' })}\n`
1097
+ );
1098
+ });
1099
+
1100
+ // update - Sync files from disk
1101
+ program
1102
+ .command('update')
1103
+ .description('Sync files from disk into the index')
1104
+ .option('--git-pull', 'run git pull in git repositories')
1105
+ .action(async (cmdOpts: Record<string, unknown>) => {
1106
+ const globals = getGlobals();
1107
+ const { update, formatUpdate } = await import('./commands/update');
1108
+ const opts = {
1109
+ gitPull: Boolean(cmdOpts.gitPull),
1110
+ verbose: globals.verbose,
1111
+ };
1112
+ const result = await update(opts);
1113
+
1114
+ if (!result.success) {
1115
+ throw new CliError('RUNTIME', result.error ?? 'Update failed');
1116
+ }
1117
+ process.stdout.write(`${formatUpdate(result, opts)}\n`);
1118
+ });
1119
+
1120
+ // embed - Generate embeddings
1121
+ program
1122
+ .command('embed')
1123
+ .description('Generate embeddings for indexed documents')
1124
+ .option('--model <uri>', 'embedding model URI')
1125
+ .option('--batch-size <num>', 'batch size', '32')
1126
+ .option('--force', 'regenerate all embeddings')
1127
+ .option('--dry-run', 'show what would be done')
1128
+ .option('--json', 'JSON output')
1129
+ .action(async (cmdOpts: Record<string, unknown>) => {
1130
+ const globals = getGlobals();
1131
+ const format = getFormat(cmdOpts);
1132
+
1133
+ const { embed, formatEmbed } = await import('./commands/embed');
1134
+ const opts = {
1135
+ model: cmdOpts.model as string | undefined,
1136
+ batchSize: parsePositiveInt('batch-size', cmdOpts.batchSize),
1137
+ force: Boolean(cmdOpts.force),
1138
+ dryRun: Boolean(cmdOpts.dryRun),
1139
+ yes: globals.yes,
1140
+ json: format === 'json',
1141
+ };
1142
+ const result = await embed(opts);
1143
+
1144
+ if (!result.success) {
1145
+ throw new CliError('RUNTIME', result.error ?? 'Embed failed');
1146
+ }
1147
+ process.stdout.write(`${formatEmbed(result, opts)}\n`);
1148
+ });
1149
+
1150
+ // cleanup - Clean stale data
1151
+ program
1152
+ .command('cleanup')
1153
+ .description('Clean orphaned data from index')
1154
+ .action(async () => {
1155
+ const { cleanup, formatCleanup } = await import('./commands/cleanup');
1156
+ const result = await cleanup();
1157
+
1158
+ if (!result.success) {
1159
+ throw new CliError('RUNTIME', result.error ?? 'Cleanup failed');
1160
+ }
1161
+ process.stdout.write(`${formatCleanup(result)}\n`);
1162
+ });
1163
+
1164
+ // reset - Reset GNO to fresh state
1165
+ program
1166
+ .command('reset')
1167
+ .description('Delete all GNO data and start fresh')
1168
+ .option('--confirm', 'confirm destructive operation')
1169
+ .option('--keep-config', 'preserve config file')
1170
+ .option('--keep-cache', 'preserve model cache')
1171
+ .action(async (cmdOpts: Record<string, unknown>) => {
1172
+ const { reset, formatReset } = await import('./commands/reset');
1173
+ const globals = getGlobals();
1174
+ const result = await reset({
1175
+ // Accept either --confirm or global --yes
1176
+ confirm: Boolean(cmdOpts.confirm) || globals.yes,
1177
+ keepConfig: Boolean(cmdOpts.keepConfig),
1178
+ keepCache: Boolean(cmdOpts.keepCache),
1179
+ });
1180
+ process.stdout.write(`${formatReset(result)}\n`);
1181
+ });
1182
+ }
1183
+
1184
+ // ─────────────────────────────────────────────────────────────────────────────
1185
+ // Skill Commands (install, uninstall, show, paths)
1186
+ // ─────────────────────────────────────────────────────────────────────────────
1187
+
1188
+ function wireSkillCommands(program: Command): void {
1189
+ const skillCmd = program
1190
+ .command('skill')
1191
+ .description('Manage GNO agent skill');
1192
+
1193
+ skillCmd
1194
+ .command('install')
1195
+ .description('Install GNO skill to Claude Code or Codex')
1196
+ .option(
1197
+ '-s, --scope <scope>',
1198
+ 'installation scope (project, user)',
1199
+ 'project'
1200
+ )
1201
+ .option(
1202
+ '-t, --target <target>',
1203
+ 'target agent (claude, codex, all)',
1204
+ 'claude'
1205
+ )
1206
+ .option('-f, --force', 'overwrite existing installation')
1207
+ .option('--json', 'JSON output')
1208
+ .action(async (cmdOpts: Record<string, unknown>) => {
1209
+ const scope = cmdOpts.scope as string;
1210
+ const target = cmdOpts.target as string;
1211
+
1212
+ // Validate scope
1213
+ if (!['project', 'user'].includes(scope)) {
1214
+ throw new CliError(
1215
+ 'VALIDATION',
1216
+ `Invalid scope: ${scope}. Must be 'project' or 'user'.`
1217
+ );
1218
+ }
1219
+ // Validate target
1220
+ if (!['claude', 'codex', 'all'].includes(target)) {
1221
+ throw new CliError(
1222
+ 'VALIDATION',
1223
+ `Invalid target: ${target}. Must be 'claude', 'codex', or 'all'.`
1224
+ );
1225
+ }
1226
+
1227
+ const { installSkill } = await import('./commands/skill/install.js');
1228
+ await installSkill({
1229
+ scope: scope as 'project' | 'user',
1230
+ target: target as 'claude' | 'codex' | 'all',
1231
+ force: Boolean(cmdOpts.force),
1232
+ json: Boolean(cmdOpts.json),
1233
+ });
1234
+ });
1235
+
1236
+ skillCmd
1237
+ .command('uninstall')
1238
+ .description('Uninstall GNO skill')
1239
+ .option(
1240
+ '-s, --scope <scope>',
1241
+ 'installation scope (project, user)',
1242
+ 'project'
1243
+ )
1244
+ .option(
1245
+ '-t, --target <target>',
1246
+ 'target agent (claude, codex, all)',
1247
+ 'claude'
1248
+ )
1249
+ .option('--json', 'JSON output')
1250
+ .action(async (cmdOpts: Record<string, unknown>) => {
1251
+ const scope = cmdOpts.scope as string;
1252
+ const target = cmdOpts.target as string;
1253
+
1254
+ // Validate scope
1255
+ if (!['project', 'user'].includes(scope)) {
1256
+ throw new CliError(
1257
+ 'VALIDATION',
1258
+ `Invalid scope: ${scope}. Must be 'project' or 'user'.`
1259
+ );
1260
+ }
1261
+ // Validate target
1262
+ if (!['claude', 'codex', 'all'].includes(target)) {
1263
+ throw new CliError(
1264
+ 'VALIDATION',
1265
+ `Invalid target: ${target}. Must be 'claude', 'codex', or 'all'.`
1266
+ );
1267
+ }
1268
+
1269
+ const { uninstallSkill } = await import('./commands/skill/uninstall.js');
1270
+ await uninstallSkill({
1271
+ scope: scope as 'project' | 'user',
1272
+ target: target as 'claude' | 'codex' | 'all',
1273
+ json: Boolean(cmdOpts.json),
1274
+ });
1275
+ });
1276
+
1277
+ skillCmd
1278
+ .command('show')
1279
+ .description('Preview skill files without installing')
1280
+ .option('--file <name>', 'specific file to show')
1281
+ .option('--all', 'show all skill files')
1282
+ .action(async (cmdOpts: Record<string, unknown>) => {
1283
+ const { showSkill } = await import('./commands/skill/show.js');
1284
+ await showSkill({
1285
+ file: cmdOpts.file as string | undefined,
1286
+ all: Boolean(cmdOpts.all),
1287
+ });
1288
+ });
1289
+
1290
+ skillCmd
1291
+ .command('paths')
1292
+ .description('Show resolved skill installation paths')
1293
+ .option(
1294
+ '-s, --scope <scope>',
1295
+ 'filter by scope (project, user, all)',
1296
+ 'all'
1297
+ )
1298
+ .option(
1299
+ '-t, --target <target>',
1300
+ 'filter by target (claude, codex, all)',
1301
+ 'all'
1302
+ )
1303
+ .option('--json', 'JSON output')
1304
+ .action(async (cmdOpts: Record<string, unknown>) => {
1305
+ const scope = cmdOpts.scope as string;
1306
+ const target = cmdOpts.target as string;
1307
+
1308
+ // Validate scope
1309
+ if (!['project', 'user', 'all'].includes(scope)) {
1310
+ throw new CliError(
1311
+ 'VALIDATION',
1312
+ `Invalid scope: ${scope}. Must be 'project', 'user', or 'all'.`
1313
+ );
1314
+ }
1315
+ // Validate target
1316
+ if (!['claude', 'codex', 'all'].includes(target)) {
1317
+ throw new CliError(
1318
+ 'VALIDATION',
1319
+ `Invalid target: ${target}. Must be 'claude', 'codex', or 'all'.`
1320
+ );
1321
+ }
1322
+
1323
+ const { showPaths } = await import('./commands/skill/paths-cmd.js');
1324
+ await showPaths({
1325
+ scope: scope as 'project' | 'user' | 'all',
1326
+ target: target as 'claude' | 'codex' | 'all',
1327
+ json: Boolean(cmdOpts.json),
1328
+ });
1329
+ });
1330
+ }