@gitset-dev/cli 2.3.3 → 2.4.1

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.
@@ -0,0 +1,613 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const { log, askQuestion, selectOption } = require('../utils/ui');
7
+ const genLocal = require('../../lib/generate-local');
8
+ const km = require('../../lib/knowledge');
9
+ const cliConfig = require('../../lib/config');
10
+
11
+ const CONFIG_FILENAME = '.gitset-knowledge.json';
12
+ const CLI_VERBS = [
13
+ 'commit', 'pr', 'issue', 'readme', 'gitignore', 'release', 'config', 'repo',
14
+ 'tree', 'status', 'init', 'template', 'license', 'labelspack', 'dependabot',
15
+ 'feedback', 'auth', 'knowledge', 'help', 'version',
16
+ ];
17
+
18
+ function flag(argv, name) {
19
+ const i = argv.indexOf(name);
20
+ return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[i + 1] : null;
21
+ }
22
+
23
+ function git(args) {
24
+ try {
25
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function loadConfig(rootDir) {
32
+ const defaults = { include: [], exclude: [], budgets: {} };
33
+ try {
34
+ const raw = fs.readFileSync(path.join(rootDir, CONFIG_FILENAME), 'utf8');
35
+ const parsed = JSON.parse(raw);
36
+ return {
37
+ include: Array.isArray(parsed.include) ? parsed.include.map(String) : [],
38
+ exclude: Array.isArray(parsed.exclude) ? parsed.exclude.map(String) : [],
39
+ budgets: parsed.budgets && typeof parsed.budgets === 'object' ? parsed.budgets : {},
40
+ };
41
+ } catch {
42
+ return defaults;
43
+ }
44
+ }
45
+
46
+ function repoLabel(rootDir) {
47
+ const remote = git(['config', '--get', 'remote.origin.url']);
48
+ const m = remote && remote.match(/[:/]([^/]+)\/([^/.]+)(?:\.git)?$/);
49
+ if (m) return `${m[1]}/${m[2]}`;
50
+ return path.basename(rootDir);
51
+ }
52
+
53
+ function repoTag() {
54
+ const described = git(['describe', '--tags', '--always']);
55
+ return described || null;
56
+ }
57
+
58
+ function printScanReport(run) {
59
+ const { files, map, batches, estimate, redactionReport } = run;
60
+ log(`\nDiscovered ${files.length} files (${run.viaGit ? 'git ls-files' : 'filesystem walk'})`, 'reset');
61
+
62
+ const byKind = {};
63
+ for (const f of files) byKind[f.kind] = (byKind[f.kind] || 0) + 1;
64
+ log(` ${Object.entries(byKind).sort().map(([k, n]) => `${k}: ${n}`).join(' · ')}`, 'dim');
65
+
66
+ if (map.manifest) {
67
+ log(`\nProject: ${map.manifest.name || '(unnamed)'}${map.manifest.version ? ` v${map.manifest.version}` : ''}`, 'cyan');
68
+ }
69
+ if (map.entryPoints.length) log(`Entry points: ${map.entryPoints.join(', ')}`, 'dim');
70
+
71
+ log('\nModules to summarize:', 'reset');
72
+ const batchesByModule = new Map();
73
+ for (const b of run.batches) {
74
+ if (!batchesByModule.has(b.module)) batchesByModule.set(b.module, { files: 0, chars: 0, parts: 0 });
75
+ const acc = batchesByModule.get(b.module);
76
+ acc.files += b.files.length;
77
+ acc.chars += b.chars;
78
+ acc.parts += 1;
79
+ }
80
+ for (const [name, acc] of [...batchesByModule.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
81
+ log(` ${name} — ${acc.files} files, ~${Math.ceil(acc.chars / 4).toLocaleString()} tokens${acc.parts > 1 ? ` (${acc.parts} calls)` : ''}`, 'cyan');
82
+ }
83
+
84
+ const docFiles = files.filter((f) => f.kind === 'doc').length;
85
+ const testFiles = files.filter((f) => f.kind === 'test').length;
86
+ log(`\nExcluded from AI input by design: ${docFiles} prose docs, ${testFiles} test files (listed structurally only).`, 'dim');
87
+
88
+ if (redactionReport.totalFindings > 0) {
89
+ log(`\nSecret scan: ${redactionReport.totalFindings} value(s) redacted before any AI call`, 'yellow');
90
+ for (const [rule, count] of Object.entries(redactionReport.findingsByRule)) {
91
+ log(` ${rule}: ${count}`, 'yellow');
92
+ }
93
+ } else {
94
+ log('\nSecret scan: clean (nothing redacted).', 'green');
95
+ }
96
+ if (redactionReport.droppedFiles.length) {
97
+ log(` Dropped entirely (too many hits): ${redactionReport.droppedFiles.join(', ')}`, 'yellow');
98
+ }
99
+
100
+ log('\nEstimated cost of a generate run:', 'reset');
101
+ log(` AI calls: ${estimate.totalCalls} (${estimate.summarizeCalls} summarize + ${estimate.writeCalls} write)`, 'cyan');
102
+ log(` Input: ~${estimate.estInputTokens.toLocaleString()} tokens · Output cap: ~${estimate.estMaxOutputTokens.toLocaleString()} tokens`, 'cyan');
103
+ log(' index.md and module-map.md are rendered locally at zero cost.', 'dim');
104
+ }
105
+
106
+ function trackUsage(usage, result) {
107
+ if (!usage || !result || !result.usage) return;
108
+ usage.calls += 1;
109
+ usage.inputTokens += result.usage.inputTokens || 0;
110
+ usage.outputTokens += result.usage.outputTokens || 0;
111
+ }
112
+
113
+ function reportUsage(usage, providerUsed, modelUsed) {
114
+ if (!usage || !usage.calls) return;
115
+ log(`\nActual usage: ${usage.calls} AI calls · ${usage.inputTokens.toLocaleString()} input + ${usage.outputTokens.toLocaleString()} output tokens${providerUsed ? ` (${providerUsed}${modelUsed ? ` / ${modelUsed}` : ''})` : ''}`, 'cyan');
116
+ }
117
+
118
+ async function confirmOrAbort(question, argv) {
119
+ if (argv.includes('--yes') || argv.includes('-y')) return true;
120
+ if (!process.stdin.isTTY) {
121
+ log('Non-interactive session: pass --yes to confirm the estimated cost.', 'red');
122
+ return false;
123
+ }
124
+ const answer = (await askQuestion(`${question} [y/N] `)).toLowerCase();
125
+ return answer === 'y' || answer === 'yes';
126
+ }
127
+
128
+ function appendModuleEntries(moduleSummaries, moduleName, entries) {
129
+ const prev = moduleSummaries[moduleName];
130
+ const prevArray = Array.isArray(prev) ? prev : [];
131
+ moduleSummaries[moduleName] = [...prevArray, ...entries];
132
+ }
133
+
134
+ async function summarizeBatches({ batches, repo, argv, cachedSummaries = {}, usage }) {
135
+ const moduleSummaries = { ...cachedSummaries };
136
+ let providerUsed = null;
137
+ let modelUsed = null;
138
+ let done = 0;
139
+ for (const batch of batches) {
140
+ done += 1;
141
+ log(` [${done}/${batches.length}] Summarizing ${batch.module}${batch.part > 1 ? ` (part ${batch.part})` : ''}…`, 'dim');
142
+ const summarizeBatch = (temperature) => genLocal.generate({
143
+ tool: 'knowledgeSummarize',
144
+ ctx: {
145
+ repo,
146
+ module: batch.module,
147
+ files: km.buildSummarizeFilesBlock(batch.files),
148
+ },
149
+ provider: flag(argv, '--provider'),
150
+ model: flag(argv, '--model'),
151
+ maxTokens: km.DEFAULTS.summarizeMaxTokens,
152
+ temperature,
153
+ interactive: true,
154
+ });
155
+
156
+ let result = await summarizeBatch(0.2);
157
+ trackUsage(usage, result);
158
+ providerUsed = result.provider;
159
+ modelUsed = result.model;
160
+ let entries = km.parseSummarizeResponse(result.raw);
161
+
162
+ if (!entries) {
163
+ log(` Structured parse failed for ${batch.module}${batch.part > 1 ? ` (part ${batch.part})` : ''}; retrying once…`, 'yellow');
164
+ result = await summarizeBatch(0.5);
165
+ trackUsage(usage, result);
166
+ providerUsed = result.provider;
167
+ modelUsed = result.model;
168
+ entries = km.parseSummarizeResponse(result.raw);
169
+ }
170
+
171
+ if (entries) {
172
+ appendModuleEntries(moduleSummaries, batch.module, entries);
173
+ } else {
174
+ log(` Still unparseable after retry; recording ${batch.files.length} file(s) as summary-unavailable (no data lost for the rest of the module).`, 'yellow');
175
+ const placeholders = batch.files.map((f) => ({
176
+ path: f.path,
177
+ purpose: '(AI summary unavailable — the model\'s response for this batch could not be parsed after a retry)',
178
+ exports: [],
179
+ dependencies: [],
180
+ notes: '',
181
+ }));
182
+ appendModuleEntries(moduleSummaries, batch.module, placeholders);
183
+ }
184
+ }
185
+ return { moduleSummaries, providerUsed, modelUsed };
186
+ }
187
+
188
+ async function writeDocs({ run, moduleSummaries, repo, argv, usage }) {
189
+ const digest = km.buildStructuralDigest(run.map, run.files);
190
+ const summariesText = km.summariesToText(moduleSummaries);
191
+ const docs = [];
192
+ let done = 0;
193
+ for (const spec of km.DOC_SPECS) {
194
+ done += 1;
195
+ log(` [${done}/${km.DOC_SPECS.length}] Writing ${spec.filename}…`, 'dim');
196
+ const generateDoc = (temperature) => genLocal.generate({
197
+ tool: 'knowledgeWrite',
198
+ ctx: {
199
+ repo,
200
+ doc: `${spec.title} (${spec.filename})`,
201
+ sections: spec.sections.join(', '),
202
+ guidance: spec.guidance || '',
203
+ digest,
204
+ summaries: summariesText,
205
+ },
206
+ provider: flag(argv, '--provider'),
207
+ model: flag(argv, '--model'),
208
+ maxTokens: km.DEFAULTS.writeMaxTokens,
209
+ temperature,
210
+ interactive: true,
211
+ });
212
+
213
+ const first = await generateDoc(0.3);
214
+ trackUsage(usage, first);
215
+ let text = first.text.trim();
216
+ let missing = km.missingSections(text, spec.sections);
217
+
218
+ if (missing.length > 0) {
219
+ log(` Incomplete (missing: ${missing.join(', ')}; finish: ${first.finishReason || 'unknown'}); retrying once…`, 'yellow');
220
+ const second = await generateDoc(0.5);
221
+ trackUsage(usage, second);
222
+ const secondText = second.text.trim();
223
+ const secondMissing = km.missingSections(secondText, spec.sections);
224
+ if (secondMissing.length < missing.length || (secondMissing.length === missing.length && secondText.length > text.length)) {
225
+ text = secondText;
226
+ missing = secondMissing;
227
+ }
228
+ if (missing.length > 0) {
229
+ log(` Still missing ${missing.join(', ')} after retry (finish: ${second.finishReason || 'unknown'}) — validation will flag it.`, 'yellow');
230
+ }
231
+ }
232
+
233
+ docs.push({ filename: spec.filename, sections: spec.sections, content: `${text}\n` });
234
+ }
235
+
236
+ docs.push({
237
+ filename: 'module-map.md',
238
+ content: km.renderModuleMapDoc({ map: run.map, moduleSummaries }),
239
+ });
240
+
241
+ return docs;
242
+ }
243
+
244
+ function validateAndReport({ docs, run }) {
245
+ const repoFiles = run.files.map((f) => f.path);
246
+
247
+ const repairs = km.repairInternalLinks(docs, { docDir: km.OUTPUT_DIR, repoFiles });
248
+ for (const repair of repairs) {
249
+ log(` Auto-repaired link in ${repair.doc}: ${repair.from} -> ${repair.to}`, 'dim');
250
+ }
251
+
252
+ const issues = km.validateDocs({
253
+ docs,
254
+ docDir: km.OUTPUT_DIR,
255
+ repoFiles,
256
+ packageScripts: (run.map.manifest && run.map.manifest.scripts) || {},
257
+ cliVerbs: [...new Set([...CLI_VERBS, ...(run.map.registeredCommands || [])])],
258
+ });
259
+ if (issues.length) {
260
+ log(`\nValidation flagged ${issues.length} issue(s):`, 'yellow');
261
+ for (const issue of issues.slice(0, 20)) {
262
+ log(` ${issue.doc}: ${issue.type} — ${issue.detail}`, 'yellow');
263
+ }
264
+ if (issues.length > 20) log(` …and ${issues.length - 20} more`, 'yellow');
265
+ } else {
266
+ log('\nValidation: all internal references verified.', 'green');
267
+ }
268
+ return issues;
269
+ }
270
+
271
+ async function persistOutput({ rootDir, docs, run, moduleSummaries, issues, providerUsed, modelUsed, argv }) {
272
+ const outDir = path.join(rootDir, km.OUTPUT_DIR);
273
+ fs.mkdirSync(outDir, { recursive: true });
274
+
275
+ const indexContent = km.renderIndexDoc({
276
+ map: run.map,
277
+ files: run.files,
278
+ tag: repoTag(),
279
+ validationSummary: issues.length
280
+ ? `${issues.length} unresolved reference(s) flagged by the local validator`
281
+ : 'all internal references verified locally',
282
+ });
283
+ fs.writeFileSync(path.join(outDir, 'index.md'), indexContent);
284
+
285
+ for (const doc of docs) {
286
+ fs.writeFileSync(path.join(outDir, doc.filename), doc.content);
287
+ }
288
+
289
+ const filesWithHashes = run.files.filter((f) => f.hash);
290
+ const state = km.buildState({
291
+ files: filesWithHashes,
292
+ moduleSummaries,
293
+ provider: providerUsed,
294
+ model: modelUsed,
295
+ tag: repoTag(),
296
+ commit: git(['rev-parse', 'HEAD']),
297
+ });
298
+ fs.writeFileSync(path.join(outDir, km.STATE_FILENAME), `${JSON.stringify(state, null, 2)}\n`);
299
+
300
+ const agentsPath = path.join(rootDir, 'AGENTS.md');
301
+ let existing = '';
302
+ try {
303
+ existing = fs.readFileSync(agentsPath, 'utf8');
304
+ } catch { }
305
+
306
+ if (existing && !existing.includes(km.AGENTS_START)) {
307
+ const ok = argv.includes('--yes') || argv.includes('-y') || (
308
+ process.stdin.isTTY
309
+ && (await askQuestion('AGENTS.md exists and was not written by Gitset. Append the knowledge-base pointer to it? [y/N] ')).toLowerCase().startsWith('y')
310
+ );
311
+ if (!ok) {
312
+ log('Left AGENTS.md untouched. Add the pointer manually if you want agents to find the knowledge base.', 'yellow');
313
+ return;
314
+ }
315
+ }
316
+ const { content, action } = km.applyAgentsPointer(existing);
317
+ fs.writeFileSync(agentsPath, content);
318
+ log(`AGENTS.md ${action}.`, 'green');
319
+ }
320
+
321
+ async function runInit(rootDir) {
322
+ const target = path.join(rootDir, CONFIG_FILENAME);
323
+ if (fs.existsSync(target)) {
324
+ log(`${CONFIG_FILENAME} already exists. Edit it directly to adjust scanning.`, 'yellow');
325
+ return 0;
326
+ }
327
+ const scaffold = {
328
+ include: [],
329
+ exclude: [],
330
+ budgets: {},
331
+ };
332
+ fs.writeFileSync(target, `${JSON.stringify(scaffold, null, 2)}\n`);
333
+ log(`Created ${CONFIG_FILENAME}.`, 'green');
334
+ log(' include: glob allowlist (empty = everything not excluded)', 'dim');
335
+ log(' exclude: glob denylist (e.g. "generated/**")', 'dim');
336
+ log(' budgets: advanced per-run limits (maxFileChars, maxBatchChars)', 'dim');
337
+ log('\nNext: gitset knowledge scan', 'cyan');
338
+ return 0;
339
+ }
340
+
341
+ async function runScan(rootDir) {
342
+ const config = loadConfig(rootDir);
343
+ log('Scanning repository (local only, zero AI calls)…', 'cyan');
344
+ const run = km.prepareRun(rootDir, config);
345
+ if (!run.batches.length) {
346
+ log('No summarizable source files found.', 'yellow');
347
+ return 1;
348
+ }
349
+ printScanReport(run);
350
+ log('\nNext: gitset knowledge generate', 'cyan');
351
+ return 0;
352
+ }
353
+
354
+ async function runGenerate(rootDir, argv) {
355
+ const config = loadConfig(rootDir);
356
+ const repo = repoLabel(rootDir);
357
+
358
+ log('Stage 1/5 · Discover + Map (local, zero AI calls)…', 'cyan');
359
+ const run = km.prepareRun(rootDir, config);
360
+ if (!run.batches.length) {
361
+ log('No summarizable source files found.', 'yellow');
362
+ return 1;
363
+ }
364
+ printScanReport(run);
365
+
366
+ const outDir = path.join(rootDir, km.OUTPUT_DIR);
367
+ if (fs.existsSync(path.join(outDir, 'index.md'))) {
368
+ log(`\nAn existing knowledge base was found in ${km.OUTPUT_DIR}/ and will be regenerated.`, 'yellow');
369
+ }
370
+
371
+ if (!(await confirmOrAbort('\nProceed with the AI calls listed above?', argv))) {
372
+ log('Aborted. Nothing was sent to any AI provider.', 'yellow');
373
+ return 0;
374
+ }
375
+
376
+ const usage = { calls: 0, inputTokens: 0, outputTokens: 0 };
377
+ try {
378
+ log('\nStage 2/5 · Summarize…', 'cyan');
379
+ const { moduleSummaries, providerUsed, modelUsed } = await summarizeBatches({ batches: run.batches, repo, argv, usage });
380
+
381
+ log('Stage 3/5 · Write…', 'cyan');
382
+ const docs = await writeDocs({ run, moduleSummaries, repo, argv, usage });
383
+
384
+ log('Stage 4/5 · Validate (local)…', 'cyan');
385
+ const issues = validateAndReport({ docs, run });
386
+
387
+ log('Stage 5/5 · Persist…', 'cyan');
388
+ await persistOutput({ rootDir, docs, run, moduleSummaries, issues, providerUsed, modelUsed, argv });
389
+
390
+ log(`\nKnowledge base written to ${km.OUTPUT_DIR}/`, 'green');
391
+ reportUsage(usage, providerUsed, modelUsed);
392
+ log('\nReview the output, then commit it like any other change.', 'dim');
393
+ log('Keep it fresh with: gitset knowledge update · or automate it in CI: gitset knowledge automate', 'cyan');
394
+ return 0;
395
+ } catch (err) {
396
+ if (err instanceof genLocal.AIError) {
397
+ log(`AI provider error (${err.code}): ${err.message}`, 'red');
398
+ return 2;
399
+ }
400
+ log(err.message, 'red');
401
+ return /gitset config/.test(err.message || '') ? 1 : 2;
402
+ }
403
+ }
404
+
405
+ async function runUpdate(rootDir, argv) {
406
+ const config = loadConfig(rootDir);
407
+ const repo = repoLabel(rootDir);
408
+ const statePath = path.join(rootDir, km.OUTPUT_DIR, km.STATE_FILENAME);
409
+
410
+ let state = null;
411
+ try {
412
+ state = km.parseState(fs.readFileSync(statePath, 'utf8'));
413
+ } catch { }
414
+ if (!state) {
415
+ log(`No previous state found at ${km.OUTPUT_DIR}/${km.STATE_FILENAME}. Run: gitset knowledge generate`, 'red');
416
+ return 1;
417
+ }
418
+
419
+ log('Diffing repository against the last run (local, zero AI calls)…', 'cyan');
420
+ const run = km.prepareRun(rootDir, config);
421
+
422
+ let diff;
423
+ const sinceRef = flag(argv, '--since');
424
+ if (sinceRef) {
425
+ const out = git(['diff', '--name-only', `${sinceRef}...HEAD`]) || git(['diff', '--name-only', sinceRef]);
426
+ if (out === null) {
427
+ log(`Could not resolve git range for --since ${sinceRef}.`, 'red');
428
+ return 1;
429
+ }
430
+ const changedSet = new Set(out.split('\n').filter(Boolean));
431
+ diff = {
432
+ changed: run.files.filter((f) => f.hash && changedSet.has(f.path)).map((f) => f.path).sort(),
433
+ added: [],
434
+ removed: [],
435
+ };
436
+ } else {
437
+ diff = km.diffAgainstState(state, run.files.filter((f) => f.hash));
438
+ }
439
+
440
+ const affected = km.affectedModules(diff, km.moduleKeyFor, run.importedBy || run.map.importedBy);
441
+ const knownModules = new Set(run.batches.map((b) => b.module));
442
+ const affectedKnown = affected.filter((m) => knownModules.has(m));
443
+
444
+ if (!affectedKnown.length) {
445
+ log('Knowledge base is up to date — no summarizable changes detected.', 'green');
446
+ return 0;
447
+ }
448
+
449
+ log(`\nChanged: ${diff.changed.length} file(s), added: ${diff.added.length}, removed: ${diff.removed.length}`, 'reset');
450
+ log(`Modules to re-summarize (changed + direct importers): ${affectedKnown.join(', ')}`, 'cyan');
451
+
452
+ const affectedBatches = run.batches.filter((b) => affectedKnown.includes(b.module));
453
+ const partialEstimate = km.estimateRun({ batches: affectedBatches, docCount: km.DOC_SPECS.length }, config.budgets);
454
+ log(`\nEstimated cost: ${partialEstimate.totalCalls} AI calls, ~${partialEstimate.estInputTokens.toLocaleString()} input tokens`, 'cyan');
455
+ log(`Unchanged modules reuse cached summaries from the last run (${Object.keys(state.moduleSummaries || {}).length} cached).`, 'dim');
456
+
457
+ if (!(await confirmOrAbort('\nProceed with the incremental update?', argv))) {
458
+ log('Aborted. Nothing was sent to any AI provider.', 'yellow');
459
+ return 0;
460
+ }
461
+
462
+ const usage = { calls: 0, inputTokens: 0, outputTokens: 0 };
463
+ try {
464
+ const cached = {};
465
+ for (const [mod, summary] of Object.entries(state.moduleSummaries || {})) {
466
+ if (!affectedKnown.includes(mod) && knownModules.has(mod)) cached[mod] = summary;
467
+ }
468
+
469
+ log('\nRe-summarizing changed modules…', 'cyan');
470
+ const { moduleSummaries, providerUsed, modelUsed } = await summarizeBatches({ batches: affectedBatches, repo, argv, cachedSummaries: cached, usage });
471
+
472
+ log('Re-writing documents…', 'cyan');
473
+ const docs = await writeDocs({ run, moduleSummaries, repo, argv, usage });
474
+
475
+ const issues = validateAndReport({ docs, run });
476
+
477
+ await persistOutput({
478
+ rootDir, docs, run, moduleSummaries, issues,
479
+ providerUsed: providerUsed || state.provider, modelUsed: modelUsed || state.model, argv,
480
+ });
481
+
482
+ log(`\nKnowledge base updated in ${km.OUTPUT_DIR}/`, 'green');
483
+ reportUsage(usage, providerUsed || state.provider, modelUsed || state.model);
484
+ return 0;
485
+ } catch (err) {
486
+ if (err instanceof genLocal.AIError) {
487
+ log(`AI provider error (${err.code}): ${err.message}`, 'red');
488
+ return 2;
489
+ }
490
+ log(err.message, 'red');
491
+ return /gitset config/.test(err.message || '') ? 1 : 2;
492
+ }
493
+ }
494
+
495
+ async function runAutomate(rootDir, argv) {
496
+ let cfg;
497
+ try {
498
+ cfg = cliConfig.resolve(flag(argv, '--provider'));
499
+ } catch (e) {
500
+ log(e.message, 'red');
501
+ return 1;
502
+ }
503
+ const envKey = cliConfig.ENV_KEYS[cfg.provider];
504
+ if (!envKey) {
505
+ log(`Provider "${cfg.provider}" can't run in CI (no standard secret env var). Configure anthropic, openai, gemini, openrouter or deepseek.`, 'red');
506
+ return 1;
507
+ }
508
+
509
+ const originHead = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
510
+ const defaultBranch = originHead ? originHead.replace(/^origin\//, '') : 'main';
511
+
512
+ let mode = flag(argv, '--mode');
513
+ if (!mode) {
514
+ if (!process.stdin.isTTY || argv.includes('--yes') || argv.includes('-y')) {
515
+ log('Pass --mode <push|releases|weekly> in non-interactive sessions.', 'red');
516
+ return 1;
517
+ }
518
+ const choice = await selectOption('When should CI refresh the knowledge base?', [
519
+ { label: `On every push to ${defaultBranch} — ${km.MODES.push}`, value: 'push' },
520
+ { label: `On every release — ${km.MODES.releases}`, value: 'releases' },
521
+ { label: `Weekly — ${km.MODES.weekly}`, value: 'weekly' },
522
+ { label: 'Cancel', value: 'cancel' },
523
+ ]);
524
+ if (choice === 'cancel') {
525
+ log('Nothing written.', 'yellow');
526
+ return 0;
527
+ }
528
+ mode = choice;
529
+ }
530
+ if (!km.MODES[mode]) {
531
+ log(`Unknown mode "${mode}". One of: ${Object.keys(km.MODES).join(', ')}`, 'red');
532
+ return 1;
533
+ }
534
+
535
+ const yaml = km.buildKnowledgeWorkflow({
536
+ mode,
537
+ provider: cfg.provider,
538
+ envKey,
539
+ model: cfg.model || null,
540
+ defaultBranch,
541
+ });
542
+
543
+ const target = path.join(rootDir, km.WORKFLOW_PATH);
544
+ log(`\nThis will create ${km.WORKFLOW_PATH}:`, 'cyan');
545
+ log(`\n${yaml.split('\n').map((l) => ` ${l}`).join('\n')}`, 'dim');
546
+ if (fs.existsSync(target)) {
547
+ log(`\n${km.WORKFLOW_PATH} already exists and will be overwritten.`, 'yellow');
548
+ }
549
+ log('\nSecurity note: CI runs need your AI key as ONE encrypted repository secret. GitHub stores it encrypted; it is only ever sent to your AI provider — never to Gitset.', 'dim');
550
+
551
+ if (!(await confirmOrAbort('\nWrite this workflow file?', argv))) {
552
+ log('Nothing written.', 'yellow');
553
+ return 0;
554
+ }
555
+
556
+ fs.mkdirSync(path.dirname(target), { recursive: true });
557
+ fs.writeFileSync(target, yaml);
558
+ log(`\nWrote ${km.WORKFLOW_PATH}.`, 'green');
559
+
560
+ const repo = repoLabel(rootDir);
561
+ log('\nNext steps:', 'cyan');
562
+ log(` 1. Add the repository secret ${envKey} with your ${cfg.provider} API key:`, 'reset');
563
+ if (repo.includes('/')) {
564
+ log(` https://github.com/${repo}/settings/secrets/actions/new`, 'dim');
565
+ } else {
566
+ log(' GitHub repo > Settings > Secrets and variables > Actions', 'dim');
567
+ }
568
+ log(' 2. Enable "Allow GitHub Actions to create and approve pull requests" (required for the update PR):', 'reset');
569
+ if (repo.includes('/')) {
570
+ log(` https://github.com/${repo}/settings/actions`, 'dim');
571
+ } else {
572
+ log(' GitHub repo > Settings > Actions > General > Workflow permissions', 'dim');
573
+ }
574
+ log(' Without this, the update still runs and commits safely — only opening the review PR fails, and the workflow run will show that failure clearly rather than hide it.', 'dim');
575
+ log(' 3. Make sure the knowledge base itself is committed (gitset knowledge generate, then commit docs/gitset-knowledge/ and AGENTS.md).', 'reset');
576
+ log(' 4. Commit and push the workflow file.', 'reset');
577
+ log('\nWhen mapped source changes land, CI opens a PR on branch gitset/knowledge-update — you review it like any docs change. Runs with no mapped changes make zero AI calls.', 'dim');
578
+ return 0;
579
+ }
580
+
581
+ async function runKnowledgeCommand(argv = []) {
582
+ const verb = argv[0] && !argv[0].startsWith('-') ? argv[0] : null;
583
+ const rest = verb ? argv.slice(1) : argv;
584
+ const rootDir = process.cwd();
585
+
586
+ if (!git(['rev-parse', '--show-toplevel']) && verb !== 'scan' && verb !== null) {
587
+ log('Tip: run from a git repository root for .gitignore-aware scanning.', 'dim');
588
+ }
589
+
590
+ switch (verb) {
591
+ case 'init':
592
+ return runInit(rootDir);
593
+ case 'scan':
594
+ return runScan(rootDir);
595
+ case 'generate':
596
+ return runGenerate(rootDir, rest);
597
+ case 'update':
598
+ return runUpdate(rootDir, rest);
599
+ case 'automate':
600
+ return runAutomate(rootDir, rest);
601
+ default:
602
+ log('Usage: gitset knowledge <init|scan|generate|update|automate> [flags]', 'reset');
603
+ log(' init scaffold .gitset-knowledge.json (optional scanning config)', 'dim');
604
+ log(' scan structural pass — zero AI calls, prints the run plan + cost estimate', 'dim');
605
+ log(' generate build docs/gitset-knowledge/ from source code (asks before spending)', 'dim');
606
+ log(' update incremental refresh: only changed modules are re-summarized', 'dim');
607
+ log(' automate write a CI workflow that keeps it fresh (always asks first)', 'dim');
608
+ log(' flags --provider <p> --model <m> --yes --since <ref> (update) --mode <push|releases|weekly> (automate)', 'dim');
609
+ return verb ? 1 : 0;
610
+ }
611
+ }
612
+
613
+ module.exports = { runKnowledgeCommand };