@codesummary/cli 0.1.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.
@@ -0,0 +1,2103 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomBytes } from 'node:crypto'
3
+ import { copyFileSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
4
+ import { mkdtempSync, rmSync } from 'node:fs'
5
+ import { createServer } from 'node:http'
6
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
7
+ import { emitKeypressEvents } from 'node:readline'
8
+ import { spawnSync } from 'node:child_process'
9
+ import { homedir, tmpdir } from 'node:os'
10
+
11
+ const DEFAULT_API_URL = 'https://dashboard.codesummary.io'
12
+ const CONFIG_DIR = '.codesummary'
13
+ const CONFIG_FILE = 'config.json'
14
+ const MANIFEST_FILE = 'manifest.json'
15
+ const LAST_SUBMITTED_MANIFEST_FILE = 'last-submitted-manifest.json'
16
+ const LAST_SOURCE_CHECK_MANIFEST_FILE = 'last-source-check-manifest.json'
17
+ const LAST_GENERATION_ATTEMPT_MANIFEST_FILE = 'last-generation-attempt-manifest.json'
18
+ const LOCAL_GENERATION_SCHEMA_FILE = 'local-generation.schema.json'
19
+ const DEFAULT_LOCAL_CONTEXT_BYTES = 180_000
20
+ const DEFAULT_LOCAL_FILE_BYTES = 24_000
21
+ const ANSI = {
22
+ reset: '\x1b[0m',
23
+ inverse: '\x1b[7m',
24
+ bold: '\x1b[1m',
25
+ dim: '\x1b[2m',
26
+ }
27
+ const DEFAULT_EXCLUDES = new Set([
28
+ '.git',
29
+ 'node_modules',
30
+ '.next',
31
+ 'dist',
32
+ 'dist-lambda',
33
+ 'build',
34
+ 'coverage',
35
+ '.turbo',
36
+ '.vercel',
37
+ '.cache',
38
+ CONFIG_DIR,
39
+ ])
40
+ const SECRET_FILE_PATTERNS = [
41
+ /^\.env($|\.)/,
42
+ /^\.npmrc$/,
43
+ /^\.yarnrc$/,
44
+ /^id_rsa$/,
45
+ /^id_ed25519$/,
46
+ ]
47
+ const GENERATED_DIR_PATTERNS = [
48
+ /^dist-.+/,
49
+ /^build-.+/,
50
+ /^\.serverless$/,
51
+ /^\.sst$/,
52
+ ]
53
+ const GENERATED_FILE_PATTERNS = [
54
+ /\.zip$/i,
55
+ /\.tar$/i,
56
+ /\.tar\.gz$/i,
57
+ /\.tgz$/i,
58
+ ]
59
+
60
+ function printHelp() {
61
+ console.log(`CodeSummary CLI
62
+
63
+ Usage:
64
+ codesummary
65
+ codesummary new [directory] [--name <source-name>] [--api-url <url>] [--provider codex|openai|anthropic|mock] [--output <dir>] [--force]
66
+ codesummary login [--api-url <url>] [--scope <scope>]
67
+ codesummary whoami [--api-url <url>]
68
+ codesummary logout
69
+ codesummary configure [--provider codex|openai|anthropic|mock] [--output <dir>] [--api-key-env <env>] [--model <model>]
70
+ codesummary config set <key> <value>
71
+ codesummary config get [key]
72
+ codesummary config clear <key>
73
+ codesummary link --site-token <token> [--api-url <url>] [--name <source-name>] [--no-verify]
74
+ codesummary manifest [--root <path>]
75
+ codesummary diff [--root <path>] [--against <manifest.json>]
76
+ codesummary export [--root <path>] [--output <dir|s3://bucket/prefix>] [--include-archive]
77
+ codesummary generate [--provider codex|openai|anthropic|mock] [--root <path>] [--output <dir>] [--api-key-env <env>] [--model <model>] [--dry-run]
78
+ codesummary submit [--token <token>] [--api-url <url>] [--site <site-id-or-slug>] [--name <source-name>] [--depth standard|deep] [--no-generate] [--wait] [--verbose]
79
+ codesummary status [--job-id <id>] [--token <token>] [--api-url <url>] [--wait] [--verbose]
80
+ codesummary version
81
+ codesummary update
82
+
83
+ Environment:
84
+ CODESUMMARY_API_KEY Product token or OAuth access token with write:knowledge scope; optional after codesummary login
85
+ CODESUMMARY_API_URL Dashboard API base URL
86
+ OPENAI_API_KEY Used by generate --provider openai unless --api-key-env is set
87
+ ANTHROPIC_API_KEY Used by generate --provider anthropic unless --api-key-env is set
88
+ `)
89
+ }
90
+
91
+ function packageInfo() {
92
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
93
+ }
94
+
95
+ function parseArgs(argv) {
96
+ const out = { _: [] }
97
+ for (let i = 0; i < argv.length; i += 1) {
98
+ const arg = argv[i]
99
+ if (!arg.startsWith('--')) {
100
+ out._.push(arg)
101
+ continue
102
+ }
103
+ const key = arg.slice(2)
104
+ if (key.startsWith('no-')) {
105
+ out[key.slice(3)] = false
106
+ continue
107
+ }
108
+ const next = argv[i + 1]
109
+ if (!next || next.startsWith('--')) {
110
+ out[key] = true
111
+ continue
112
+ }
113
+ out[key] = next
114
+ i += 1
115
+ }
116
+ return out
117
+ }
118
+
119
+ function configPath(root = process.cwd()) {
120
+ return join(root, CONFIG_DIR, CONFIG_FILE)
121
+ }
122
+
123
+ function readConfig(root = process.cwd()) {
124
+ const path = configPath(root)
125
+ if (!existsSync(path)) return {}
126
+ return JSON.parse(readFileSync(path, 'utf8'))
127
+ }
128
+
129
+ function writeConfig(config, root = process.cwd()) {
130
+ const dir = join(root, CONFIG_DIR)
131
+ mkdirSync(dir, { recursive: true })
132
+ writeFileSync(configPath(root), `${JSON.stringify(config, null, 2)}\n`)
133
+ }
134
+
135
+ function configKeyAllowed(key) {
136
+ return [
137
+ 'apiUrl',
138
+ 'sourceName',
139
+ 'depth',
140
+ 'site.id',
141
+ 'site.slug',
142
+ 'site.name',
143
+ 'localGeneration.provider',
144
+ 'localGeneration.output',
145
+ 'localGeneration.model',
146
+ 'localGeneration.apiKeyEnv',
147
+ ].includes(key)
148
+ }
149
+
150
+ function readConfigValue(config, key) {
151
+ return key.split('.').reduce((value, part) => value?.[part], config)
152
+ }
153
+
154
+ function writeConfigValue(config, key, value) {
155
+ if (!configKeyAllowed(key)) {
156
+ throw new Error(`Unsupported config key: ${key}`)
157
+ }
158
+ const next = { ...config }
159
+ const parts = key.split('.')
160
+ let cursor = next
161
+ for (let i = 0; i < parts.length - 1; i += 1) {
162
+ const part = parts[i]
163
+ cursor[part] = { ...(cursor[part] || {}) }
164
+ cursor = cursor[part]
165
+ }
166
+ cursor[parts[parts.length - 1]] = value
167
+ return next
168
+ }
169
+
170
+ function clearConfigValue(config, key) {
171
+ if (!configKeyAllowed(key)) {
172
+ throw new Error(`Unsupported config key: ${key}`)
173
+ }
174
+ const next = { ...config }
175
+ const parts = key.split('.')
176
+ let cursor = next
177
+ for (let i = 0; i < parts.length - 1; i += 1) {
178
+ const part = parts[i]
179
+ if (!cursor[part] || typeof cursor[part] !== 'object') return next
180
+ cursor[part] = { ...cursor[part] }
181
+ cursor = cursor[part]
182
+ }
183
+ delete cursor[parts[parts.length - 1]]
184
+ return next
185
+ }
186
+
187
+ function authPath() {
188
+ return join(homedir(), CONFIG_DIR, 'auth.json')
189
+ }
190
+
191
+ function readAuth() {
192
+ const path = authPath()
193
+ if (!existsSync(path)) return { sessions: {}, defaultKey: null }
194
+ return JSON.parse(readFileSync(path, 'utf8'))
195
+ }
196
+
197
+ function writeAuth(auth) {
198
+ const dir = join(homedir(), CONFIG_DIR)
199
+ mkdirSync(dir, { recursive: true })
200
+ writeFileSync(authPath(), `${JSON.stringify(auth, null, 2)}\n`)
201
+ }
202
+
203
+ function sleep(ms) {
204
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms))
205
+ }
206
+
207
+ function cliAuthHeaders(token) {
208
+ return { 'X-CodeSummary-Token': token }
209
+ }
210
+
211
+ function shouldExclude(name, relPath) {
212
+ if (DEFAULT_EXCLUDES.has(name)) return true
213
+ if (GENERATED_DIR_PATTERNS.some((pattern) => pattern.test(name))) return true
214
+ if (GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(name))) return true
215
+ if (relPath.includes(`${CONFIG_DIR}/`)) return true
216
+ if (SECRET_FILE_PATTERNS.some((pattern) => pattern.test(name))) return true
217
+ if (relPath === join(CONFIG_DIR, CONFIG_FILE)) return true
218
+ return false
219
+ }
220
+
221
+ function pathIsInside(parent, child) {
222
+ const rel = relative(resolve(parent), resolve(child))
223
+ return rel === '' || (!!rel && !rel.startsWith('..'))
224
+ }
225
+
226
+ function walkFiles(root, dir = root, files = [], options = {}) {
227
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
228
+ const abs = join(dir, entry.name)
229
+ const rel = relative(root, abs)
230
+ if ((options.excludePaths || []).some((path) => pathIsInside(path, abs))) continue
231
+ if (shouldExclude(entry.name, rel)) continue
232
+ if (entry.isDirectory()) {
233
+ walkFiles(root, abs, files, options)
234
+ } else if (entry.isFile()) {
235
+ files.push(abs)
236
+ }
237
+ }
238
+ return files
239
+ }
240
+
241
+ function sha256File(path) {
242
+ const hash = createHash('sha256')
243
+ const stream = createReadStream(path)
244
+ return new Promise((resolvePromise, reject) => {
245
+ stream.on('data', (chunk) => hash.update(chunk))
246
+ stream.on('error', reject)
247
+ stream.on('end', () => resolvePromise(hash.digest('hex')))
248
+ })
249
+ }
250
+
251
+ async function writeManifest(root, options = {}) {
252
+ const files = walkFiles(root, root, [], options)
253
+ const manifestFiles = []
254
+ for (const file of files) {
255
+ const stats = statSync(file)
256
+ manifestFiles.push({
257
+ path: relative(root, file).replaceAll('\\', '/'),
258
+ bytes: stats.size,
259
+ sha256: await sha256File(file),
260
+ modifiedAt: stats.mtime.toISOString(),
261
+ })
262
+ }
263
+ manifestFiles.sort((a, b) => a.path.localeCompare(b.path))
264
+ const manifest = {
265
+ schemaVersion: 1,
266
+ generatedAt: new Date().toISOString(),
267
+ rootName: basename(root),
268
+ files: manifestFiles,
269
+ }
270
+ const dir = join(root, CONFIG_DIR)
271
+ mkdirSync(dir, { recursive: true })
272
+ writeFileSync(join(dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
273
+ return manifest
274
+ }
275
+
276
+ function writeLastSubmittedManifest(root, manifest) {
277
+ const dir = join(root, CONFIG_DIR)
278
+ mkdirSync(dir, { recursive: true })
279
+ writeFileSync(join(dir, LAST_SUBMITTED_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
280
+ }
281
+
282
+ function writeLastSourceCheckManifest(root, manifest) {
283
+ const dir = join(root, CONFIG_DIR)
284
+ mkdirSync(dir, { recursive: true })
285
+ writeFileSync(join(dir, LAST_SOURCE_CHECK_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
286
+ }
287
+
288
+ function writeLastGenerationAttemptManifest(root, manifest) {
289
+ const dir = join(root, CONFIG_DIR)
290
+ mkdirSync(dir, { recursive: true })
291
+ writeFileSync(join(dir, LAST_GENERATION_ATTEMPT_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
292
+ }
293
+
294
+ function readManifestFile(path) {
295
+ const manifest = JSON.parse(readFileSync(path, 'utf8'))
296
+ if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files)) {
297
+ throw new Error(`Invalid CodeSummary manifest: ${path}`)
298
+ }
299
+ return manifest
300
+ }
301
+
302
+ function summarizeManifestDiff(previousManifest, currentManifest) {
303
+ const previous = new Map(previousManifest.files.map((file) => [file.path, file]))
304
+ const current = new Map(currentManifest.files.map((file) => [file.path, file]))
305
+ const added = []
306
+ const removed = []
307
+ const changed = []
308
+ const unchanged = []
309
+
310
+ for (const [path, file] of current.entries()) {
311
+ const old = previous.get(path)
312
+ if (!old) {
313
+ added.push(path)
314
+ } else if (old.sha256 !== file.sha256 || old.bytes !== file.bytes) {
315
+ changed.push(path)
316
+ } else {
317
+ unchanged.push(path)
318
+ }
319
+ }
320
+
321
+ for (const path of previous.keys()) {
322
+ if (!current.has(path)) removed.push(path)
323
+ }
324
+
325
+ return {
326
+ added: added.sort(),
327
+ changed: changed.sort(),
328
+ removed: removed.sort(),
329
+ unchanged: unchanged.length,
330
+ }
331
+ }
332
+
333
+ function createZip(root, options = {}) {
334
+ const tmp = mkdtempSync(join(tmpdir(), 'codesummary-'))
335
+ const archive = join(tmp, 'workspace.zip')
336
+ const excludes = [
337
+ '*.git/*',
338
+ '*node_modules/*',
339
+ '*.next/*',
340
+ '*dist/*',
341
+ '*dist-*/*',
342
+ '*build/*',
343
+ '*build-*/*',
344
+ '*coverage/*',
345
+ '*.serverless/*',
346
+ '*.sst/*',
347
+ '*.turbo/*',
348
+ '*.vercel/*',
349
+ '*.cache/*',
350
+ '*.codesummary/*',
351
+ '.codesummary/*',
352
+ '*.env',
353
+ '*.env.*',
354
+ '*.npmrc',
355
+ '*.yarnrc',
356
+ '*.zip',
357
+ '*.tar',
358
+ '*.tar.gz',
359
+ '*.tgz',
360
+ ]
361
+ for (const path of options.excludePaths || []) {
362
+ const rel = relative(root, path).replaceAll('\\', '/')
363
+ if (!rel || rel.startsWith('..')) continue
364
+ excludes.push(rel, `${rel}/*`, `*${rel}/*`)
365
+ }
366
+ const args = ['-qr', archive, '.', ...excludes.flatMap((pattern) => ['-x', pattern])]
367
+ const result = spawnSync('zip', args, { cwd: root, encoding: 'utf8' })
368
+ if (result.status !== 0) {
369
+ rmSync(tmp, { recursive: true, force: true })
370
+ throw new Error(result.stderr || 'zip failed')
371
+ }
372
+ return { archive, cleanup: () => rmSync(tmp, { recursive: true, force: true }) }
373
+ }
374
+
375
+ function writeExportFiles(root, outputDir, manifest, options = {}) {
376
+ mkdirSync(outputDir, { recursive: true })
377
+ const generatedAt = new Date().toISOString()
378
+ const metadata = {
379
+ schemaVersion: 1,
380
+ generatedAt,
381
+ rootName: manifest.rootName,
382
+ fileCount: manifest.files.length,
383
+ totalBytes: manifest.files.reduce((sum, file) => sum + file.bytes, 0),
384
+ includesSourceArchive: Boolean(options.includeArchive),
385
+ }
386
+
387
+ writeFileSync(join(outputDir, 'codesummary-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
388
+ writeFileSync(join(outputDir, 'codesummary-export.json'), `${JSON.stringify(metadata, null, 2)}\n`)
389
+ writeFileSync(
390
+ join(outputDir, 'README.md'),
391
+ [
392
+ '# CodeSummary Export',
393
+ '',
394
+ 'This bundle was generated by the CodeSummary CLI.',
395
+ '',
396
+ '- `codesummary-manifest.json` contains file paths, byte sizes, mtimes, and SHA-256 hashes.',
397
+ '- `codesummary-export.json` contains bundle metadata.',
398
+ options.includeArchive
399
+ ? '- `workspace.zip` contains the submitted workspace with default secret/build exclusions applied.'
400
+ : '- Source contents are not included. Re-run with `--include-archive` only when exporting to infrastructure you control.',
401
+ '',
402
+ ].join('\n')
403
+ )
404
+ return metadata
405
+ }
406
+
407
+ function syncDirectoryToS3(localDir, output) {
408
+ const result = spawnSync('aws', ['s3', 'sync', localDir, output], { encoding: 'utf8' })
409
+ if (result.status !== 0) {
410
+ throw new Error(result.stderr || 'aws s3 sync failed')
411
+ }
412
+ }
413
+
414
+ function localGenerationSchema() {
415
+ return {
416
+ type: 'object',
417
+ additionalProperties: false,
418
+ required: ['files'],
419
+ properties: {
420
+ files: {
421
+ type: 'array',
422
+ minItems: 1,
423
+ items: {
424
+ type: 'object',
425
+ additionalProperties: false,
426
+ required: ['path', 'content'],
427
+ properties: {
428
+ path: {
429
+ type: 'string',
430
+ pattern: '^[A-Za-z0-9][A-Za-z0-9._/ -]*\\.md$',
431
+ },
432
+ title: { type: 'string' },
433
+ content: { type: 'string', minLength: 20 },
434
+ },
435
+ },
436
+ },
437
+ },
438
+ }
439
+ }
440
+
441
+ function localGenerationSystemPrompt() {
442
+ return [
443
+ 'You are CodeSummary local generation.',
444
+ 'Generate concise, accurate Markdown documentation for the submitted codebase context.',
445
+ 'Return only JSON matching this shape: {"files":[{"path":"README.md","title":"...","content":"# ..."}]}.',
446
+ 'Do not include prose outside JSON.',
447
+ 'Do not invent APIs, services, or deployment facts that are not supported by the context.',
448
+ 'Prefer practical docs engineers can use immediately: overview, architecture, setup, important workflows, and operational notes.',
449
+ 'Use relative Markdown links only when they point to files you are creating.',
450
+ 'Never include secrets or credentials.',
451
+ ].join('\n')
452
+ }
453
+
454
+ function isLikelyTextFile(filePath) {
455
+ const name = basename(filePath).toLowerCase()
456
+ if ([
457
+ 'readme',
458
+ 'makefile',
459
+ 'dockerfile',
460
+ 'package.json',
461
+ 'tsconfig.json',
462
+ 'terraform.tfvars.example',
463
+ ].includes(name)) return true
464
+ return /\.(ts|tsx|js|jsx|mjs|cjs|json|md|mdx|yml|yaml|toml|tf|sh|sql|py|go|rs|java|kt|swift|rb|php|cs|css|scss|html|txt)$/i.test(filePath)
465
+ }
466
+
467
+ function priorityForFile(relPath) {
468
+ const lower = relPath.toLowerCase()
469
+ if (/(^|\/)(readme|agents|package|pnpm-workspace|turbo|next\.config|vite\.config|dockerfile)/.test(lower)) return 0
470
+ if (/(^|\/)(src|app|apps|packages|lib|server|api|routes)\//.test(lower)) return 1
471
+ if (/(^|\/)(infra|terraform|environments|modules|deploy|scripts|\.github)\//.test(lower)) return 2
472
+ if (/\.(md|mdx|yml|yaml|json|tf|ts|tsx|js|jsx)$/i.test(lower)) return 3
473
+ return 4
474
+ }
475
+
476
+ function buildLocalContext(root, manifest, options = {}) {
477
+ const maxBytes = Number(options['max-context-bytes'] || DEFAULT_LOCAL_CONTEXT_BYTES)
478
+ const maxFileBytes = Number(options['max-file-bytes'] || DEFAULT_LOCAL_FILE_BYTES)
479
+ const outputDir = options.outputDir ? resolve(options.outputDir) : null
480
+ const files = walkFiles(root, root, [], options)
481
+ .map((abs) => {
482
+ const rel = relative(root, abs).replaceAll('\\', '/')
483
+ return { abs, rel, priority: priorityForFile(rel), bytes: statSync(abs).size }
484
+ })
485
+ .filter((file) => {
486
+ if (!isLikelyTextFile(file.rel) || file.bytes <= 0) return false
487
+ if (!outputDir) return true
488
+ const relToOutput = relative(outputDir, file.abs)
489
+ return relToOutput.startsWith('..') || relToOutput === ''
490
+ })
491
+ .sort((a, b) => a.priority - b.priority || a.rel.localeCompare(b.rel))
492
+
493
+ const selected = []
494
+ let usedBytes = 0
495
+ for (const file of files) {
496
+ if (usedBytes >= maxBytes) break
497
+ const limit = Math.min(file.bytes, maxFileBytes, maxBytes - usedBytes)
498
+ let content = readFileSync(file.abs, 'utf8')
499
+ if (content.length > limit) {
500
+ content = `${content.slice(0, limit)}\n\n[truncated by CodeSummary local context pack]\n`
501
+ }
502
+ usedBytes += Buffer.byteLength(content)
503
+ selected.push({ path: file.rel, bytes: file.bytes, includedBytes: Buffer.byteLength(content), content })
504
+ }
505
+
506
+ const tree = manifest.files.map((file) => `${file.path} (${file.bytes} bytes)`).join('\n')
507
+ const contents = selected.map((file) => [
508
+ `--- FILE: ${file.path}`,
509
+ file.content,
510
+ ].join('\n')).join('\n\n')
511
+
512
+ return {
513
+ selected,
514
+ prompt: [
515
+ localGenerationSystemPrompt(),
516
+ '',
517
+ `Repository root name: ${manifest.rootName}`,
518
+ `Manifest file count: ${manifest.files.length}`,
519
+ '',
520
+ 'FULL FILE MANIFEST:',
521
+ tree,
522
+ '',
523
+ 'SELECTED LOCAL FILE CONTENTS:',
524
+ contents || '(no text files selected)',
525
+ '',
526
+ 'Generate a useful initial documentation set. Keep it grounded in this context.',
527
+ ].join('\n'),
528
+ }
529
+ }
530
+
531
+ function extractJsonObject(text) {
532
+ const trimmed = String(text || '').trim()
533
+ if (!trimmed) throw new Error('Provider returned an empty response.')
534
+ try {
535
+ return JSON.parse(trimmed)
536
+ } catch {
537
+ const start = trimmed.indexOf('{')
538
+ const end = trimmed.lastIndexOf('}')
539
+ if (start === -1 || end === -1 || end <= start) {
540
+ throw new Error('Provider response did not contain a JSON object.')
541
+ }
542
+ return JSON.parse(trimmed.slice(start, end + 1))
543
+ }
544
+ }
545
+
546
+ function validateGeneratedDocs(payload) {
547
+ if (!payload || !Array.isArray(payload.files) || payload.files.length === 0) {
548
+ throw new Error('Generated output must include at least one file.')
549
+ }
550
+ const normalized = []
551
+ for (const file of payload.files) {
552
+ if (!file || typeof file.path !== 'string' || typeof file.content !== 'string') {
553
+ throw new Error('Each generated file must include path and content.')
554
+ }
555
+ const path = file.path.replaceAll('\\', '/').replace(/^\/+/, '')
556
+ if (path.includes('..') || !path.endsWith('.md')) {
557
+ throw new Error(`Unsafe generated path: ${file.path}`)
558
+ }
559
+ if (file.content.trim().length < 20) {
560
+ throw new Error(`Generated file is too small: ${path}`)
561
+ }
562
+ normalized.push({ path, title: file.title || null, content: file.content.trimEnd() + '\n' })
563
+ }
564
+ return { files: normalized }
565
+ }
566
+
567
+ function writeGeneratedDocs(outputDir, generated) {
568
+ mkdirSync(outputDir, { recursive: true })
569
+ for (const file of generated.files) {
570
+ const target = resolve(outputDir, file.path)
571
+ const safeOutputRoot = resolve(outputDir)
572
+ const rel = relative(safeOutputRoot, target)
573
+ if (rel.startsWith('..') || rel === '') {
574
+ throw new Error(`Generated path escapes output directory: ${file.path}`)
575
+ }
576
+ mkdirSync(dirname(target), { recursive: true })
577
+ writeFileSync(target, file.content)
578
+ }
579
+ }
580
+
581
+ function runCodexProvider(prompt, options) {
582
+ const tmp = mkdtempSync(join(tmpdir(), 'codesummary-codex-'))
583
+ const outputPath = join(tmp, 'last-message.txt')
584
+ const schemaPath = join(tmp, LOCAL_GENERATION_SCHEMA_FILE)
585
+ writeFileSync(schemaPath, `${JSON.stringify(localGenerationSchema(), null, 2)}\n`)
586
+
587
+ try {
588
+ const command = String(options['codex-command'] || 'codex')
589
+ const args = [
590
+ 'exec',
591
+ '--skip-git-repo-check',
592
+ '--ephemeral',
593
+ '--sandbox',
594
+ 'read-only',
595
+ '--ask-for-approval',
596
+ 'never',
597
+ '--output-schema',
598
+ schemaPath,
599
+ '--output-last-message',
600
+ outputPath,
601
+ ]
602
+ if (options.model) args.push('--model', String(options.model))
603
+ args.push('-')
604
+ const result = spawnSync(command, args, {
605
+ input: prompt,
606
+ encoding: 'utf8',
607
+ maxBuffer: 10 * 1024 * 1024,
608
+ })
609
+ if (result.status !== 0) {
610
+ throw new Error(result.stderr || result.stdout || `${command} exec failed`)
611
+ }
612
+ if (!existsSync(outputPath)) {
613
+ throw new Error('Codex did not write an output message.')
614
+ }
615
+ return readFileSync(outputPath, 'utf8')
616
+ } finally {
617
+ rmSync(tmp, { recursive: true, force: true })
618
+ }
619
+ }
620
+
621
+ async function callOpenAiCompatible(apiUrl, apiKey, model, prompt) {
622
+ const response = await fetch(apiUrl, {
623
+ method: 'POST',
624
+ headers: {
625
+ Authorization: `Bearer ${apiKey}`,
626
+ 'Content-Type': 'application/json',
627
+ },
628
+ body: JSON.stringify({
629
+ model,
630
+ messages: [
631
+ { role: 'system', content: localGenerationSystemPrompt() },
632
+ { role: 'user', content: prompt },
633
+ ],
634
+ temperature: 0.2,
635
+ response_format: { type: 'json_object' },
636
+ }),
637
+ })
638
+ const body = await response.json().catch(() => ({}))
639
+ if (!response.ok) {
640
+ throw new Error(body.error?.message || body.message || `OpenAI-compatible provider failed with HTTP ${response.status}`)
641
+ }
642
+ return body.choices?.[0]?.message?.content || ''
643
+ }
644
+
645
+ async function callAnthropic(apiUrl, apiKey, model, prompt) {
646
+ const response = await fetch(apiUrl, {
647
+ method: 'POST',
648
+ headers: {
649
+ 'x-api-key': apiKey,
650
+ 'anthropic-version': '2023-06-01',
651
+ 'Content-Type': 'application/json',
652
+ },
653
+ body: JSON.stringify({
654
+ model,
655
+ max_tokens: 5000,
656
+ temperature: 0.2,
657
+ system: localGenerationSystemPrompt(),
658
+ messages: [{ role: 'user', content: prompt }],
659
+ }),
660
+ })
661
+ const body = await response.json().catch(() => ({}))
662
+ if (!response.ok) {
663
+ throw new Error(body.error?.message || body.message || `Anthropic provider failed with HTTP ${response.status}`)
664
+ }
665
+ return (body.content || []).map((part) => part.type === 'text' ? part.text : '').join('\n')
666
+ }
667
+
668
+ async function runLocalProvider(provider, prompt, options) {
669
+ if (provider === 'mock') {
670
+ return JSON.stringify({
671
+ files: [
672
+ {
673
+ path: 'README.md',
674
+ title: 'Local CodeSummary Documentation',
675
+ content: [
676
+ '# Local CodeSummary Documentation',
677
+ '',
678
+ 'This mock documentation was generated without calling an inference provider.',
679
+ '',
680
+ 'Use this mode to verify local packaging, schema validation, and file writing.',
681
+ ].join('\n'),
682
+ },
683
+ ],
684
+ })
685
+ }
686
+
687
+ if (provider === 'codex') {
688
+ return runCodexProvider(prompt, options)
689
+ }
690
+
691
+ if (provider === 'openai') {
692
+ const envName = String(options['api-key-env'] || 'OPENAI_API_KEY')
693
+ const apiKey = process.env[envName]
694
+ if (!apiKey) throw new Error(`Missing ${envName}. The key stays local; export it before running generate.`)
695
+ const apiUrl = String(options['api-url'] || 'https://api.openai.com/v1/chat/completions')
696
+ const model = String(options.model || 'gpt-4.1')
697
+ return callOpenAiCompatible(apiUrl, apiKey, model, prompt)
698
+ }
699
+
700
+ if (provider === 'anthropic') {
701
+ const envName = String(options['api-key-env'] || 'ANTHROPIC_API_KEY')
702
+ const apiKey = process.env[envName]
703
+ if (!apiKey) throw new Error(`Missing ${envName}. The key stays local; export it before running generate.`)
704
+ const apiUrl = String(options['api-url'] || 'https://api.anthropic.com/v1/messages')
705
+ const model = String(options.model || 'claude-sonnet-4-20250514')
706
+ return callAnthropic(apiUrl, apiKey, model, prompt)
707
+ }
708
+
709
+ throw new Error('Invalid provider. Use --provider codex, openai, anthropic, or mock.')
710
+ }
711
+
712
+ async function generateLocal(options) {
713
+ const root = resolve(options.root || process.cwd())
714
+ const config = readConfig(root)
715
+ const localConfig = localGenerationConfig(config)
716
+ const outputDir = resolveOutputDir(root, options.output || localConfig.output || 'docs')
717
+ const provider = normalizeProvider(options.provider || localConfig.provider || 'codex')
718
+ const providerOptions = {
719
+ ...options,
720
+ model: options.model || localConfig.model,
721
+ 'api-key-env': options['api-key-env'] || localConfig.apiKeyEnv,
722
+ }
723
+ const manifest = await writeManifest(root, { excludePaths: [outputDir] })
724
+ const context = buildLocalContext(root, manifest, { ...providerOptions, outputDir })
725
+
726
+ if (options['dry-run']) {
727
+ console.log(JSON.stringify({
728
+ provider,
729
+ root,
730
+ output: outputDir,
731
+ manifestFiles: manifest.files.length,
732
+ selectedFiles: context.selected.map((file) => ({
733
+ path: file.path,
734
+ bytes: file.bytes,
735
+ includedBytes: file.includedBytes,
736
+ })),
737
+ promptBytes: Buffer.byteLength(context.prompt),
738
+ sendsSourceToCodeSummary: false,
739
+ sendsProviderKeyToCodeSummary: false,
740
+ }, null, 2))
741
+ return
742
+ }
743
+
744
+ const raw = await runLocalProvider(provider, context.prompt, providerOptions)
745
+ const generated = validateGeneratedDocs(extractJsonObject(raw))
746
+ writeGeneratedDocs(outputDir, generated)
747
+ writeConfig({ ...config, lastLocalGenerate: { provider, output: outputDir, generatedAt: new Date().toISOString() } }, root)
748
+ console.log(JSON.stringify({
749
+ provider,
750
+ output: outputDir,
751
+ files: generated.files.map((file) => file.path),
752
+ sendsSourceToCodeSummary: false,
753
+ sendsProviderKeyToCodeSummary: false,
754
+ }, null, 2))
755
+ }
756
+
757
+ async function exportBundle(options) {
758
+ const root = resolve(options.root || process.cwd())
759
+ const output = options.output || join(root, CONFIG_DIR, 'export')
760
+ if (typeof output !== 'string') {
761
+ throw new Error('export requires --output <dir|s3://bucket/prefix> when --output is provided')
762
+ }
763
+ const includeArchive = options['include-archive'] === true
764
+ const manifest = await writeManifest(root)
765
+ const isS3Output = typeof output === 'string' && output.startsWith('s3://')
766
+ const targetDir = isS3Output ? mkdtempSync(join(tmpdir(), 'codesummary-export-')) : resolve(output)
767
+ let archiveCleanup = null
768
+ let archive = null
769
+
770
+ try {
771
+ if (includeArchive) {
772
+ const zipped = createZip(root, { excludePaths: [targetDir] })
773
+ archive = zipped.archive
774
+ archiveCleanup = zipped.cleanup
775
+ }
776
+ const metadata = writeExportFiles(root, targetDir, manifest, { includeArchive })
777
+ if (includeArchive) {
778
+ copyFileSync(archive, join(targetDir, 'workspace.zip'))
779
+ }
780
+
781
+ if (isS3Output) {
782
+ syncDirectoryToS3(targetDir, output)
783
+ console.log(JSON.stringify({ output, ...metadata }, null, 2))
784
+ } else {
785
+ console.log(JSON.stringify({ output: targetDir, ...metadata }, null, 2))
786
+ }
787
+ } finally {
788
+ if (archiveCleanup) archiveCleanup()
789
+ if (isS3Output) rmSync(targetDir, { recursive: true, force: true })
790
+ }
791
+ }
792
+
793
+ function resolveApiUrl(options, config) {
794
+ return (options['api-url'] || process.env.CODESUMMARY_API_URL || config.apiUrl || DEFAULT_API_URL).replace(/\/$/, '')
795
+ }
796
+
797
+ function base64Url(buffer) {
798
+ return Buffer.from(buffer).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
799
+ }
800
+
801
+ function createPkcePair() {
802
+ const verifier = base64Url(randomBytes(32))
803
+ const challenge = base64Url(createHash('sha256').update(verifier).digest())
804
+ return { verifier, challenge }
805
+ }
806
+
807
+ function tokenExpiryFromResponse(body) {
808
+ const expiresIn = Number(body.expires_in || 3600)
809
+ return new Date(Date.now() + Math.max(60, expiresIn - 60) * 1000).toISOString()
810
+ }
811
+
812
+ function oauthTokenIsFresh(oauth) {
813
+ if (!oauth?.accessToken || !oauth.expiresAt) return false
814
+ return new Date(oauth.expiresAt).getTime() > Date.now() + 30 * 1000
815
+ }
816
+
817
+ function oauthSessionKey(apiUrl, resource) {
818
+ return `${apiUrl}|${resource || 'default'}`
819
+ }
820
+
821
+ function resolveStoredOAuth(config) {
822
+ const auth = readAuth()
823
+ const key = config.oauthKey || auth.defaultKey
824
+ if (key && auth.sessions?.[key]) {
825
+ return { auth, key, session: auth.sessions[key], source: 'global' }
826
+ }
827
+ if (config.oauth?.accessToken) {
828
+ return { auth, key: null, session: config.oauth, source: 'workspace' }
829
+ }
830
+ return { auth, key: null, session: null, source: null }
831
+ }
832
+
833
+ async function refreshOAuthToken(apiUrl, session, storage, config, root) {
834
+ const refreshToken = session?.refreshToken
835
+ if (!refreshToken) {
836
+ throw new Error('OAuth session expired. Run `codesummary login` again.')
837
+ }
838
+
839
+ const params = new URLSearchParams()
840
+ params.set('grant_type', 'refresh_token')
841
+ params.set('refresh_token', refreshToken)
842
+
843
+ const tokenUrl = `${apiUrl}/api/oauth/token`
844
+ if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] POST ${tokenUrl} grant_type=refresh_token`)
845
+ const response = await fetch(tokenUrl, {
846
+ method: 'POST',
847
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
848
+ body: params.toString(),
849
+ })
850
+ const body = await response.json().catch(() => ({}))
851
+ if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] oauth refresh status=${response.status} body=${JSON.stringify(body).slice(0, 500)}`)
852
+ if (!response.ok) {
853
+ throw new Error(body.error_description || body.message || `OAuth refresh failed with HTTP ${response.status}. Run \`codesummary login\` again.`)
854
+ }
855
+
856
+ const nextSession = {
857
+ ...session,
858
+ accessToken: body.access_token,
859
+ refreshToken: body.refresh_token || refreshToken,
860
+ expiresAt: tokenExpiryFromResponse(body),
861
+ scope: body.scope || session?.scope,
862
+ }
863
+
864
+ if (storage.source === 'global' && storage.key) {
865
+ const auth = {
866
+ ...storage.auth,
867
+ sessions: {
868
+ ...(storage.auth.sessions || {}),
869
+ [storage.key]: nextSession,
870
+ },
871
+ defaultKey: storage.auth.defaultKey || storage.key,
872
+ }
873
+ writeAuth(auth)
874
+ return { token: nextSession.accessToken, config }
875
+ }
876
+
877
+ const nextConfig = { ...config, oauth: nextSession }
878
+ writeConfig(nextConfig, root)
879
+ return { token: nextSession.accessToken, config: nextConfig }
880
+ }
881
+
882
+ async function resolveAuth(options, config, root) {
883
+ const staticToken = options.token || options['site-token'] || process.env.CODESUMMARY_API_KEY || config.siteToken
884
+ if (staticToken) return { token: staticToken, config }
885
+
886
+ const apiUrl = resolveApiUrl(options, config)
887
+ const storage = resolveStoredOAuth(config)
888
+ if (oauthTokenIsFresh(storage.session)) {
889
+ return { token: storage.session.accessToken, config }
890
+ }
891
+ if (storage.session?.refreshToken) {
892
+ return refreshOAuthToken(apiUrl, storage.session, storage, config, root)
893
+ }
894
+
895
+ throw new Error('Missing token. Run `codesummary login`, `codesummary link --site-token <token>`, or set CODESUMMARY_API_KEY.')
896
+ }
897
+
898
+ function normalizeOAuthResource(value, options = {}) {
899
+ const raw = value || options.site || options.target
900
+ if (!raw) return ''
901
+ if (/^https?:\/\//i.test(raw)) return raw
902
+ const trimmed = String(raw).replace(/^\/+|\/+$/g, '')
903
+ const parts = trimmed.split('/')
904
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
905
+ throw new Error('OAuth resource must be <owner>/<site> or a full /mcp/<owner>/<site> URL.')
906
+ }
907
+ const mcpOrigin = String(options['mcp-origin'] || 'https://webhooks.codesummary.io').replace(/\/$/, '')
908
+ return `${mcpOrigin}/mcp/${encodeURIComponent(parts[0])}/${encodeURIComponent(parts[1])}`
909
+ }
910
+
911
+ async function registerOAuthClient(apiUrl, redirectUri) {
912
+ const response = await fetch(`${apiUrl}/api/oauth/register`, {
913
+ method: 'POST',
914
+ headers: { 'Content-Type': 'application/json' },
915
+ body: JSON.stringify({
916
+ client_name: 'CodeSummary CLI',
917
+ redirect_uris: [redirectUri],
918
+ grant_types: ['authorization_code', 'refresh_token'],
919
+ response_types: ['code'],
920
+ token_endpoint_auth_method: 'none',
921
+ }),
922
+ })
923
+ const body = await response.json().catch(() => ({}))
924
+ if (!response.ok) {
925
+ throw new Error(body.error_description || body.message || `OAuth client registration failed with HTTP ${response.status}`)
926
+ }
927
+ return body
928
+ }
929
+
930
+ function openBrowser(url) {
931
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
932
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
933
+ const result = spawnSync(command, args, { stdio: 'ignore' })
934
+ return result.status === 0
935
+ }
936
+
937
+ function closeServer(server) {
938
+ if (!server.listening) return
939
+ server.close()
940
+ }
941
+
942
+ function waitForOAuthCallback(server, expectedState) {
943
+ return new Promise((resolvePromise, reject) => {
944
+ const timeout = setTimeout(() => {
945
+ closeServer(server)
946
+ reject(new Error('Timed out waiting for OAuth callback.'))
947
+ }, 10 * 60 * 1000)
948
+
949
+ server.on('request', (req, res) => {
950
+ try {
951
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
952
+ if (url.pathname !== '/callback') {
953
+ res.writeHead(404)
954
+ res.end('Not found')
955
+ return
956
+ }
957
+ const error = url.searchParams.get('error')
958
+ const code = url.searchParams.get('code')
959
+ const state = url.searchParams.get('state')
960
+ if (state !== expectedState) {
961
+ throw new Error('OAuth state mismatch.')
962
+ }
963
+ if (error) {
964
+ throw new Error(url.searchParams.get('error_description') || error)
965
+ }
966
+ if (!code) {
967
+ throw new Error('OAuth callback did not include a code.')
968
+ }
969
+ clearTimeout(timeout)
970
+ res.writeHead(200, { 'Content-Type': 'text/html' })
971
+ res.end('<!doctype html><title>CodeSummary CLI</title><body style="font-family: system-ui; padding: 2rem;">CodeSummary CLI is logged in. You can close this window.</body>')
972
+ closeServer(server)
973
+ resolvePromise(code)
974
+ } catch (error) {
975
+ clearTimeout(timeout)
976
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
977
+ res.end(error instanceof Error ? error.message : String(error))
978
+ closeServer(server)
979
+ reject(error)
980
+ }
981
+ })
982
+ })
983
+ }
984
+
985
+ async function exchangeOAuthCode(apiUrl, clientId, redirectUri, code, verifier) {
986
+ const params = new URLSearchParams()
987
+ params.set('grant_type', 'authorization_code')
988
+ params.set('client_id', clientId)
989
+ params.set('redirect_uri', redirectUri)
990
+ params.set('code', code)
991
+ params.set('code_verifier', verifier)
992
+
993
+ const response = await fetch(`${apiUrl}/api/oauth/token`, {
994
+ method: 'POST',
995
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
996
+ body: params.toString(),
997
+ })
998
+ const body = await response.json().catch(() => ({}))
999
+ if (!response.ok) {
1000
+ throw new Error(body.error_description || body.message || `OAuth token exchange failed with HTTP ${response.status}`)
1001
+ }
1002
+ return body
1003
+ }
1004
+
1005
+ async function login(options) {
1006
+ const root = resolve(options.root || process.cwd())
1007
+ const existing = readConfig(root)
1008
+ const apiUrl = resolveApiUrl(options, existing)
1009
+ const resource = normalizeOAuthResource(options.resource, options)
1010
+ const scope = String(options.scope || 'write:knowledge')
1011
+ const server = createServer()
1012
+
1013
+ await new Promise((resolvePromise, reject) => {
1014
+ server.once('error', reject)
1015
+ server.listen(0, '127.0.0.1', resolvePromise)
1016
+ })
1017
+
1018
+ const address = server.address()
1019
+ const port = typeof address === 'object' && address ? address.port : null
1020
+ if (!port) throw new Error('Failed to allocate OAuth callback port.')
1021
+ try {
1022
+ const redirectUri = `http://127.0.0.1:${port}/callback`
1023
+ const client = await registerOAuthClient(apiUrl, redirectUri)
1024
+ const { verifier, challenge } = createPkcePair()
1025
+ const state = base64Url(randomBytes(18))
1026
+
1027
+ const authorizeUrl = new URL(`${apiUrl}/oauth/authorize`)
1028
+ authorizeUrl.searchParams.set('response_type', 'code')
1029
+ authorizeUrl.searchParams.set('client_id', client.client_id)
1030
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri)
1031
+ authorizeUrl.searchParams.set('code_challenge', challenge)
1032
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256')
1033
+ authorizeUrl.searchParams.set('state', state)
1034
+ authorizeUrl.searchParams.set('scope', scope)
1035
+ if (resource) authorizeUrl.searchParams.set('resource', resource)
1036
+
1037
+ console.error(`Opening browser for CodeSummary login: ${authorizeUrl.toString()}`)
1038
+ if (!openBrowser(authorizeUrl.toString())) {
1039
+ console.error(`Open this URL to continue:\n${authorizeUrl.toString()}`)
1040
+ }
1041
+
1042
+ const code = await waitForOAuthCallback(server, state)
1043
+ const tokenBody = await exchangeOAuthCode(apiUrl, client.client_id, redirectUri, code, verifier)
1044
+ const oauth = {
1045
+ clientId: client.client_id,
1046
+ accessToken: tokenBody.access_token,
1047
+ refreshToken: tokenBody.refresh_token,
1048
+ expiresAt: tokenExpiryFromResponse(tokenBody),
1049
+ scope: tokenBody.scope || scope,
1050
+ resource: resource || null,
1051
+ }
1052
+ const key = oauthSessionKey(apiUrl, resource)
1053
+ const auth = readAuth()
1054
+ writeAuth({
1055
+ ...auth,
1056
+ sessions: {
1057
+ ...(auth.sessions || {}),
1058
+ [key]: oauth,
1059
+ },
1060
+ defaultKey: key,
1061
+ })
1062
+
1063
+ const sourceName = options.name || existing.sourceName || basename(root)
1064
+ const next = {
1065
+ ...existing,
1066
+ apiUrl,
1067
+ sourceName,
1068
+ oauthKey: key,
1069
+ oauthResource: resource || null,
1070
+ }
1071
+ delete next.oauth
1072
+
1073
+ let linked = null
1074
+ try {
1075
+ linked = await fetchLinkedSite(apiUrl, oauth.accessToken)
1076
+ next.site = linked.site || existing.site
1077
+ } catch {
1078
+ linked = null
1079
+ }
1080
+
1081
+ writeConfig(next, root)
1082
+ if (linked?.site) {
1083
+ console.log(`Logged in and linked ${sourceName} to ${linked.site.name} (${linked.site.id}) at ${apiUrl}`)
1084
+ } else {
1085
+ console.log(`Logged in to CodeSummary at ${apiUrl}`)
1086
+ }
1087
+ } catch (error) {
1088
+ closeServer(server)
1089
+ throw error
1090
+ }
1091
+ }
1092
+
1093
+ async function logout(options) {
1094
+ const root = resolve(options.root || process.cwd())
1095
+ const existing = readConfig(root)
1096
+ const auth = readAuth()
1097
+ const key = existing.oauthKey || auth.defaultKey
1098
+ if (key && auth.sessions?.[key]) {
1099
+ const sessions = { ...auth.sessions }
1100
+ delete sessions[key]
1101
+ writeAuth({
1102
+ ...auth,
1103
+ sessions,
1104
+ defaultKey: auth.defaultKey === key ? null : auth.defaultKey,
1105
+ })
1106
+ }
1107
+ const next = { ...existing }
1108
+ delete next.oauth
1109
+ delete next.oauthKey
1110
+ delete next.oauthResource
1111
+ delete next.siteToken
1112
+ writeConfig(next, root)
1113
+ console.log('Logged out of CodeSummary CLI for this workspace.')
1114
+ }
1115
+
1116
+ async function whoami(options) {
1117
+ const root = resolve(options.root || process.cwd())
1118
+ const config = readConfig(root)
1119
+ const apiUrl = resolveApiUrl(options, config)
1120
+ const auth = await resolveAuth(options, config, root)
1121
+ const storage = resolveStoredOAuth(auth.config)
1122
+ let linked = null
1123
+ try {
1124
+ linked = await fetchLinkedSite(apiUrl, auth.token)
1125
+ } catch {
1126
+ linked = null
1127
+ }
1128
+
1129
+ console.log(JSON.stringify({
1130
+ apiUrl,
1131
+ workspace: root,
1132
+ sourceName: auth.config.sourceName || basename(root),
1133
+ auth: {
1134
+ type: storage.session ? 'oauth' : 'token',
1135
+ scope: storage.session?.scope || null,
1136
+ resource: storage.session?.resource || null,
1137
+ expiresAt: storage.session?.expiresAt || null,
1138
+ storage: storage.source || (auth.token ? 'environment-or-config-token' : null),
1139
+ },
1140
+ site: linked?.site || auth.config.site || null,
1141
+ account: linked?.account || null,
1142
+ }, null, 2))
1143
+ }
1144
+
1145
+ function terminalStatus(status) {
1146
+ return status === 'completed' || status === 'failed' || status === 'cancelled'
1147
+ }
1148
+
1149
+ function normalizeDepth(value) {
1150
+ if (!value) return 'standard'
1151
+ if (value === 'standard' || value === 'deep') return value
1152
+ throw new Error('Invalid depth. Use --depth standard or --depth deep.')
1153
+ }
1154
+
1155
+ function normalizeProvider(value) {
1156
+ const provider = String(value || 'codex').toLowerCase()
1157
+ if (['codex', 'openai', 'anthropic', 'mock'].includes(provider)) return provider
1158
+ throw new Error('Invalid provider. Use codex, openai, anthropic, or mock.')
1159
+ }
1160
+
1161
+ function localGenerationConfig(config) {
1162
+ return config.localGeneration || {}
1163
+ }
1164
+
1165
+ function resolveOutputDir(root, output = 'docs') {
1166
+ const value = String(output)
1167
+ return isAbsolute(value) ? resolve(value) : resolve(root, value)
1168
+ }
1169
+
1170
+ async function configure(options) {
1171
+ const root = resolve(options.root || process.cwd())
1172
+ const existing = readConfig(root)
1173
+ const current = localGenerationConfig(existing)
1174
+ const nextLocal = { ...current }
1175
+
1176
+ if (options.provider) nextLocal.provider = normalizeProvider(options.provider)
1177
+ if (options.output) nextLocal.output = String(options.output)
1178
+ if (options.model) nextLocal.model = String(options.model)
1179
+ if (options['api-key-env']) nextLocal.apiKeyEnv = String(options['api-key-env'])
1180
+ if (options.default) {
1181
+ throw new Error('Bare `codesummary` now starts the hosted interactive flow. Use `codesummary generate` for local inference.')
1182
+ }
1183
+
1184
+ const next = {
1185
+ ...existing,
1186
+ localGeneration: nextLocal,
1187
+ }
1188
+ writeConfig(next, root)
1189
+ console.log(JSON.stringify({
1190
+ path: configPath(root),
1191
+ localGeneration: nextLocal,
1192
+ storesProviderKey: false,
1193
+ }, null, 2))
1194
+ }
1195
+
1196
+ async function configCommand(options) {
1197
+ const root = resolve(options.root || process.cwd())
1198
+ const [subcommand, key, ...valueParts] = options._
1199
+ const existing = readConfig(root)
1200
+
1201
+ if (!subcommand || subcommand === 'list') {
1202
+ console.log(JSON.stringify(existing, null, 2))
1203
+ return
1204
+ }
1205
+
1206
+ if (subcommand === 'get') {
1207
+ if (!key) {
1208
+ console.log(JSON.stringify(existing, null, 2))
1209
+ return
1210
+ }
1211
+ if (!configKeyAllowed(key)) throw new Error(`Unsupported config key: ${key}`)
1212
+ const value = readConfigValue(existing, key)
1213
+ if (value === undefined) {
1214
+ process.exitCode = 1
1215
+ return
1216
+ }
1217
+ if (typeof value === 'object') {
1218
+ console.log(JSON.stringify(value, null, 2))
1219
+ } else {
1220
+ console.log(String(value))
1221
+ }
1222
+ return
1223
+ }
1224
+
1225
+ if (subcommand === 'set') {
1226
+ if (!key || valueParts.length === 0) {
1227
+ throw new Error('Usage: codesummary config set <key> <value>')
1228
+ }
1229
+ let value = valueParts.join(' ')
1230
+ if (key === 'depth') value = normalizeDepth(value)
1231
+ if (key === 'localGeneration.provider') value = normalizeProvider(value)
1232
+ const next = writeConfigValue(existing, key, value)
1233
+ writeConfig(next, root)
1234
+ console.log(`Set ${key}.`)
1235
+ return
1236
+ }
1237
+
1238
+ if (subcommand === 'clear') {
1239
+ if (!key) throw new Error('Usage: codesummary config clear <key>')
1240
+ const next = clearConfigValue(existing, key)
1241
+ writeConfig(next, root)
1242
+ console.log(`Cleared ${key}.`)
1243
+ return
1244
+ }
1245
+
1246
+ throw new Error('Usage: codesummary config [list|get|set|clear]')
1247
+ }
1248
+
1249
+ async function newWorkspace(options) {
1250
+ const target = resolve(options._[0] || options.root || process.cwd())
1251
+ const sourceName = options.name || basename(target)
1252
+ const existingConfigPath = configPath(target)
1253
+ const targetExists = existsSync(target)
1254
+ const targetEntries = targetExists ? readdirSync(target) : []
1255
+ const isCurrentDirectory = target === resolve(process.cwd())
1256
+
1257
+ if (targetExists && targetEntries.length > 0 && !isCurrentDirectory && !options.force) {
1258
+ throw new Error(`Directory is not empty: ${target}. Pass --force to initialize it without deleting files.`)
1259
+ }
1260
+
1261
+ mkdirSync(target, { recursive: true })
1262
+ const existing = readConfig(target)
1263
+ const next = {
1264
+ ...existing,
1265
+ apiUrl: resolveApiUrl(options, existing),
1266
+ sourceName,
1267
+ }
1268
+ if (options.provider || options.output || options.model || options['api-key-env']) {
1269
+ next.localGeneration = {
1270
+ ...(existing.localGeneration || {}),
1271
+ ...(options.provider ? { provider: normalizeProvider(options.provider) } : {}),
1272
+ ...(options.output ? { output: String(options.output) } : {}),
1273
+ ...(options.model ? { model: String(options.model) } : {}),
1274
+ ...(options['api-key-env'] ? { apiKeyEnv: String(options['api-key-env']) } : {}),
1275
+ }
1276
+ }
1277
+ writeConfig(next, target)
1278
+
1279
+ const readmePath = join(target, 'README.md')
1280
+ if (!targetExists || targetEntries.length === 0) {
1281
+ writeFileSync(
1282
+ readmePath,
1283
+ [
1284
+ `# ${sourceName}`,
1285
+ '',
1286
+ 'This workspace is ready for CodeSummary.',
1287
+ '',
1288
+ 'Run `codesummary login`, then `codesummary` to create or update hosted knowledge.',
1289
+ '',
1290
+ ].join('\n')
1291
+ )
1292
+ }
1293
+
1294
+ console.log('Initialized CodeSummary workspace.')
1295
+ console.log(` Path: ${target}`)
1296
+ console.log(` Config: ${existingConfigPath}`)
1297
+ console.log(` Source: ${sourceName}`)
1298
+ console.log(` API: ${next.apiUrl}`)
1299
+ console.log('')
1300
+ console.log('Next: run `codesummary login`, then `codesummary`.')
1301
+ }
1302
+
1303
+ function printVersion() {
1304
+ const pkg = packageInfo()
1305
+ console.log(`${pkg.name} ${pkg.version}`)
1306
+ }
1307
+
1308
+ function printUpdateInstructions() {
1309
+ const pkg = packageInfo()
1310
+ const installName = pkg.name === 'codesummary' ? 'codesummary' : pkg.name
1311
+ console.log(`Update with: npm install -g ${installName}@latest`)
1312
+ }
1313
+
1314
+ async function fetchLinkedSite(apiUrl, token) {
1315
+ const response = await fetch(`${apiUrl}/api/cli/site`, {
1316
+ headers: cliAuthHeaders(token),
1317
+ })
1318
+ const body = await response.json().catch(() => ({}))
1319
+ if (!response.ok) {
1320
+ throw new Error(body.message || body.error || `Link verification failed with HTTP ${response.status}`)
1321
+ }
1322
+ return body
1323
+ }
1324
+
1325
+ async function fetchJobStatus(apiUrl, token, jobId) {
1326
+ const response = await fetch(`${apiUrl}/api/cli/jobs/${jobId}`, {
1327
+ headers: cliAuthHeaders(token),
1328
+ })
1329
+ const body = await response.json().catch(() => ({}))
1330
+ if (!response.ok) {
1331
+ throw new Error(body.message || body.error || `Status failed with HTTP ${response.status}`)
1332
+ }
1333
+ return body
1334
+ }
1335
+
1336
+ function jobStatusTitle(status) {
1337
+ if (status === 'pending') return 'Knowledge generation is queued.'
1338
+ if (status === 'processing') return 'Knowledge generation is running.'
1339
+ if (status === 'completed') return 'Knowledge generation completed.'
1340
+ if (status === 'failed') return 'Knowledge generation failed.'
1341
+ if (status === 'cancelled') return 'Knowledge generation was cancelled.'
1342
+ if (status === 'skipped') return 'Knowledge generation was skipped.'
1343
+ return 'Knowledge generation status.'
1344
+ }
1345
+
1346
+ function formatElapsed(value) {
1347
+ if (!value) return null
1348
+ const ms = Date.now() - new Date(value).getTime()
1349
+ if (!Number.isFinite(ms) || ms < 0) return null
1350
+ const seconds = Math.floor(ms / 1000)
1351
+ if (seconds < 60) return `${seconds}s ago`
1352
+ const minutes = Math.floor(seconds / 60)
1353
+ if (minutes < 60) return `${minutes}m ago`
1354
+ const hours = Math.floor(minutes / 60)
1355
+ return `${hours}h ago`
1356
+ }
1357
+
1358
+ function siteDashboardUrl(apiUrl, site) {
1359
+ if (!apiUrl || !site?.id) return null
1360
+ return `${String(apiUrl).replace(/\/$/, '')}/dashboard/sites/${site.id}`
1361
+ }
1362
+
1363
+ function isStagingApiUrl(apiUrl) {
1364
+ return /(^|[./-])staging([./-]|$)/i.test(String(apiUrl || ''))
1365
+ }
1366
+
1367
+ function isZeroSpendJob(job) {
1368
+ if (!job || job.status !== 'completed') return false
1369
+ const cost = Number(job.cost_usd ?? 0)
1370
+ const credits = Number(job.credits_used ?? 0)
1371
+ const input = Number(job.input_tokens ?? 0)
1372
+ const output = Number(job.output_tokens ?? 0)
1373
+ return cost === 0 && credits === 0 && input === 0 && output === 0
1374
+ }
1375
+
1376
+ function printJobStatus(body, options = {}) {
1377
+ const job = body.job
1378
+ if (options.verbose) {
1379
+ console.log(JSON.stringify(body, null, 2))
1380
+ if (job?.status === 'failed' && job.error) {
1381
+ console.error(`Job failed: ${job.error}`)
1382
+ }
1383
+ return
1384
+ }
1385
+
1386
+ if (!job) {
1387
+ console.log('No knowledge generation job found.')
1388
+ return
1389
+ }
1390
+
1391
+ console.log(jobStatusTitle(job.status))
1392
+ if (body.source?.name) console.log(` Source: ${body.source.name}`)
1393
+ if (body.site?.name) console.log(` Site: ${body.site.name}`)
1394
+ const siteUrl = siteDashboardUrl(options.apiUrl, body.site)
1395
+ if (siteUrl) console.log(` Open: ${siteUrl}`)
1396
+ console.log(` Job: ${job.id}`)
1397
+ console.log(` Status: ${job.status || 'unknown'}`)
1398
+ const createdAgo = formatElapsed(job.created_at)
1399
+ if (createdAgo) console.log(` Created: ${createdAgo}`)
1400
+ if (job.progress_message) console.log(` Progress: ${job.progress_message}`)
1401
+ if (job.credits_used) console.log(` Credits used: ${job.credits_used}`)
1402
+ if (job?.status === 'failed' && job.error) {
1403
+ console.log(` Error: ${job.error}`)
1404
+ }
1405
+ if (isStagingApiUrl(options.apiUrl) && isZeroSpendJob(job)) {
1406
+ console.log(' Staging: completed without provider spend. This verifies the pipeline, not final documentation quality.')
1407
+ }
1408
+ if (body.siteJob?.id) {
1409
+ console.log('')
1410
+ console.log(`Site update: ${body.siteJob.status || 'unknown'}`)
1411
+ console.log(` Job: ${body.siteJob.id}`)
1412
+ if (body.siteJob.progress_message) console.log(` Progress: ${body.siteJob.progress_message}`)
1413
+ if (isStagingApiUrl(options.apiUrl) && isZeroSpendJob(body.siteJob)) {
1414
+ console.log(' Staging: site docs were produced by the no-spend test path.')
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ function printSubmitSummary(output, options = {}) {
1420
+ const site = output.site
1421
+ const source = output.source
1422
+ const job = output.job
1423
+ const generate = options.generate !== false
1424
+ const siteUrl = siteDashboardUrl(options.apiUrl, site)
1425
+
1426
+ if (job?.id) {
1427
+ console.log('Knowledge generation started.')
1428
+ console.log(` Source: ${source?.name || 'workspace'}`)
1429
+ if (site?.name) console.log(` Site: ${site.name}`)
1430
+ if (siteUrl) console.log(` Open: ${siteUrl}`)
1431
+ console.log(` Job: ${job.id}`)
1432
+ console.log(` Status: ${job.status || 'pending'}`)
1433
+ console.log('')
1434
+ console.log('Run `codesummary status --wait` to follow progress.')
1435
+ return
1436
+ }
1437
+
1438
+ if (generate) {
1439
+ console.log('Source uploaded, but no generation job was created.')
1440
+ if (site?.name) console.log(` Site: ${site.name}`)
1441
+ if (siteUrl) console.log(` Open: ${siteUrl}`)
1442
+ console.log('Run `codesummary status` if you already have a job id, or try again.')
1443
+ return
1444
+ }
1445
+
1446
+ const retainedArchive = source?.uploadRetained !== false
1447
+ console.log(retainedArchive ? 'Source uploaded.' : 'Setup verified.')
1448
+ console.log(` Files: ${output.manifestFileCount ?? source?.manifest?.fileCount ?? 'unknown'}`)
1449
+ if (site?.name) console.log(` Site: ${site.name}`)
1450
+ if (siteUrl) console.log(` Open: ${siteUrl}`)
1451
+ if (!retainedArchive) console.log('No source archive was retained.')
1452
+ console.log('No generation job was started.')
1453
+ }
1454
+
1455
+ function maybePromoteCompletedGenerationBaseline(root, config, body) {
1456
+ const job = body?.job
1457
+ if (!job?.id || job.status !== 'completed') return false
1458
+ const lastSubmit = config.lastSubmit || null
1459
+ if (!lastSubmit?.generated || lastSubmit.jobId !== job.id) return false
1460
+
1461
+ const attemptPath = join(root, CONFIG_DIR, LAST_GENERATION_ATTEMPT_MANIFEST_FILE)
1462
+ if (!existsSync(attemptPath)) return false
1463
+ const manifest = readManifestFile(attemptPath)
1464
+ writeLastSubmittedManifest(root, manifest)
1465
+ return true
1466
+ }
1467
+
1468
+ async function waitForJob(apiUrl, token, jobId, options = {}) {
1469
+ const intervalMs = Number(options['poll-interval-ms'] || 5000)
1470
+ const timeoutMs = Number(options['timeout-ms'] || 20 * 60 * 1000)
1471
+ const started = Date.now()
1472
+ let lastStatus = ''
1473
+ let lastSiteStatus = ''
1474
+ let lastHeartbeat = 0
1475
+
1476
+ for (;;) {
1477
+ const body = await fetchJobStatus(apiUrl, token, jobId)
1478
+ const status = body.job?.status || 'unknown'
1479
+ const now = Date.now()
1480
+ if (status !== lastStatus || now - lastHeartbeat >= Math.max(intervalMs, 15000)) {
1481
+ const message = body.job?.progress_message ? ` - ${body.job.progress_message}` : ''
1482
+ const elapsed = Math.floor((now - started) / 1000)
1483
+ console.error(`Job ${jobId}: ${status}${message} (${elapsed}s elapsed)`)
1484
+ lastStatus = status
1485
+ lastHeartbeat = now
1486
+ }
1487
+ const siteJob = body.siteJob
1488
+ const siteStatus = siteJob?.status || ''
1489
+ if (siteJob?.id && siteStatus !== lastSiteStatus) {
1490
+ const message = siteJob.progress_message ? ` - ${siteJob.progress_message}` : ''
1491
+ console.error(`Site job ${siteJob.id}: ${siteStatus}${message}`)
1492
+ lastSiteStatus = siteStatus
1493
+ }
1494
+ if (terminalStatus(status)) {
1495
+ if (status === 'completed' && siteJob?.id && !terminalStatus(siteStatus)) {
1496
+ if (Date.now() - started > timeoutMs) {
1497
+ throw new Error(`Timed out waiting for site job ${siteJob.id}`)
1498
+ }
1499
+ await sleep(intervalMs)
1500
+ continue
1501
+ }
1502
+ printJobStatus(body, { ...options, apiUrl })
1503
+ if (status !== 'completed' || (siteJob?.id && siteStatus !== 'completed')) process.exitCode = 1
1504
+ return body
1505
+ }
1506
+ if (Date.now() - started > timeoutMs) {
1507
+ throw new Error(`Timed out waiting for job ${jobId}`)
1508
+ }
1509
+ await sleep(intervalMs)
1510
+ }
1511
+ }
1512
+
1513
+ async function submit(options) {
1514
+ const root = resolve(options.root || process.cwd())
1515
+ const config = readConfig(root)
1516
+ const auth = await resolveAuth(options, config, root)
1517
+ const token = auth.token
1518
+ const activeConfig = auth.config
1519
+ const apiUrl = resolveApiUrl(options, activeConfig)
1520
+ const name = options.name || activeConfig.sourceName || basename(root)
1521
+ const depth = normalizeDepth(options.depth || activeConfig.depth)
1522
+ const generate = options.generate !== false
1523
+ const site = options.site || options['site-id'] || activeConfig.site?.id || activeConfig.site?.slug || null
1524
+
1525
+ const manifest = await writeManifest(root)
1526
+ const { archive, cleanup } = createZip(root)
1527
+ try {
1528
+ const buffer = readFileSync(archive)
1529
+ const form = new FormData()
1530
+ form.set('name', name)
1531
+ form.set('sourceRoot', root)
1532
+ form.set('depth', depth)
1533
+ form.set('generate', String(generate))
1534
+ form.set('manifest', JSON.stringify(manifest))
1535
+ if (site) form.set('site', String(site))
1536
+ form.set('file', new Blob([buffer], { type: 'application/zip' }), `${name}.zip`)
1537
+
1538
+ const submitUrl = `${apiUrl}/api/cli/submit`
1539
+ if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] POST ${submitUrl} generate=${generate} fileBytes=${buffer.length}`)
1540
+ const response = await fetch(submitUrl, {
1541
+ method: 'POST',
1542
+ headers: cliAuthHeaders(token),
1543
+ body: form,
1544
+ })
1545
+ const body = await response.json().catch(() => ({}))
1546
+ if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] submit status=${response.status} body=${JSON.stringify(body).slice(0, 500)}`)
1547
+ if (!response.ok) {
1548
+ throw new Error(body.message || body.error || `Submit failed with HTTP ${response.status}`)
1549
+ }
1550
+ const output = { ...body, manifestFileCount: manifest.files.length }
1551
+ const nextConfig = {
1552
+ ...activeConfig,
1553
+ apiUrl,
1554
+ sourceName: name,
1555
+ depth,
1556
+ lastJobId: body.job?.id || activeConfig.lastJobId,
1557
+ lastSourceId: body.source?.id || activeConfig.lastSourceId,
1558
+ lastSiteId: body.site?.id || activeConfig.lastSiteId,
1559
+ site: body.site || activeConfig.site,
1560
+ lastSubmit: {
1561
+ generated: Boolean(generate && body.job?.id),
1562
+ jobId: body.job?.id || null,
1563
+ sourceId: body.source?.id || null,
1564
+ siteId: body.site?.id || null,
1565
+ manifestFileCount: manifest.files.length,
1566
+ submittedAt: new Date().toISOString(),
1567
+ },
1568
+ }
1569
+ writeConfig(nextConfig, root)
1570
+ if (generate && body.job?.id) {
1571
+ writeLastGenerationAttemptManifest(root, manifest)
1572
+ } else if (!generate) {
1573
+ writeLastSourceCheckManifest(root, manifest)
1574
+ }
1575
+ if (options.verbose) {
1576
+ console.log(JSON.stringify(output, null, 2))
1577
+ } else if (!options.silent) {
1578
+ printSubmitSummary(output, { generate, apiUrl })
1579
+ }
1580
+ if (options.wait && body.job?.id) {
1581
+ const completed = await waitForJob(apiUrl, token, body.job.id, options)
1582
+ if (completed.job?.status === 'completed') {
1583
+ writeLastSubmittedManifest(root, manifest)
1584
+ }
1585
+ }
1586
+ return output
1587
+ } finally {
1588
+ cleanup()
1589
+ }
1590
+ }
1591
+
1592
+ async function link(options) {
1593
+ const root = resolve(options.root || process.cwd())
1594
+ const existing = readConfig(root)
1595
+ const apiUrl = resolveApiUrl(options, existing)
1596
+ const siteToken = options['site-token'] || options.token || process.env.CODESUMMARY_API_KEY || existing.siteToken
1597
+ if (!siteToken) {
1598
+ throw new Error('link requires --site-token <token>')
1599
+ }
1600
+
1601
+ const linked = options.verify === false ? null : await fetchLinkedSite(apiUrl, siteToken)
1602
+ const sourceName = options.name || existing.sourceName || basename(root)
1603
+ const next = {
1604
+ ...existing,
1605
+ apiUrl,
1606
+ sourceName,
1607
+ siteToken,
1608
+ site: linked?.site || existing.site,
1609
+ }
1610
+ writeConfig(next, root)
1611
+
1612
+ if (linked?.site) {
1613
+ console.log(`Linked ${sourceName} to ${linked.site.name} (${linked.site.id}) at ${apiUrl}`)
1614
+ } else {
1615
+ console.log(`Linked ${sourceName} to ${apiUrl}`)
1616
+ }
1617
+ }
1618
+
1619
+ async function status(options) {
1620
+ const root = resolve(options.root || process.cwd())
1621
+ const config = readConfig(root)
1622
+ const auth = await resolveAuth(options, config, root)
1623
+ const token = auth.token
1624
+ const activeConfig = auth.config
1625
+ const apiUrl = resolveApiUrl(options, activeConfig)
1626
+ const jobId = options['job-id'] || activeConfig.lastJobId
1627
+ if (!jobId) {
1628
+ throw new Error('Missing job id. Pass --job-id <id> or run submit first.')
1629
+ }
1630
+
1631
+ if (options.wait) {
1632
+ const body = await waitForJob(apiUrl, token, jobId, options)
1633
+ maybePromoteCompletedGenerationBaseline(root, config, body)
1634
+ return
1635
+ }
1636
+
1637
+ const body = await fetchJobStatus(apiUrl, token, jobId)
1638
+ maybePromoteCompletedGenerationBaseline(root, config, body)
1639
+ printJobStatus(body, { ...options, apiUrl })
1640
+ }
1641
+
1642
+ async function diff(options) {
1643
+ const root = resolve(options.root || process.cwd())
1644
+ const baselinePath = resolve(
1645
+ options.against || join(root, CONFIG_DIR, LAST_SUBMITTED_MANIFEST_FILE)
1646
+ )
1647
+ if (!existsSync(baselinePath)) {
1648
+ throw new Error(
1649
+ `Missing baseline manifest. Run submit first or pass --against <manifest.json>. Expected ${baselinePath}`
1650
+ )
1651
+ }
1652
+
1653
+ const previousManifest = readManifestFile(baselinePath)
1654
+ const currentManifest = await writeManifest(root)
1655
+ const summary = summarizeManifestDiff(previousManifest, currentManifest)
1656
+ console.log(
1657
+ JSON.stringify(
1658
+ {
1659
+ baseline: baselinePath,
1660
+ current: join(root, CONFIG_DIR, MANIFEST_FILE),
1661
+ counts: {
1662
+ added: summary.added.length,
1663
+ changed: summary.changed.length,
1664
+ removed: summary.removed.length,
1665
+ unchanged: summary.unchanged,
1666
+ },
1667
+ added: summary.added,
1668
+ changed: summary.changed,
1669
+ removed: summary.removed,
1670
+ },
1671
+ null,
1672
+ 2
1673
+ )
1674
+ )
1675
+ }
1676
+
1677
+ function isInteractiveTerminal() {
1678
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
1679
+ }
1680
+
1681
+ async function promptChoice(title, choices) {
1682
+ if (!isInteractiveTerminal()) {
1683
+ throw new Error('Interactive mode requires a terminal. Use an explicit command such as `codesummary submit`.')
1684
+ }
1685
+
1686
+ emitKeypressEvents(process.stdin)
1687
+ const wasRaw = process.stdin.isRaw
1688
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true)
1689
+
1690
+ let selected = 0
1691
+ let renderedLines = 0
1692
+ const terminalWidth = () => Math.max(40, process.stdout.columns || 80)
1693
+ const padLine = (value) => {
1694
+ const visibleLength = value.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').length
1695
+ return `${value}${' '.repeat(Math.max(0, terminalWidth() - visibleLength))}`
1696
+ }
1697
+ const render = () => {
1698
+ if (renderedLines > 0) {
1699
+ process.stdout.write(`\x1b[${renderedLines}A`)
1700
+ process.stdout.write('\x1b[0J')
1701
+ }
1702
+ const lines = [
1703
+ title,
1704
+ ...choices.flatMap((choice, index) => {
1705
+ const isSelected = index === selected
1706
+ const marker = isSelected ? '›' : ' '
1707
+ const labelText = `${marker} ${choice.label}`
1708
+ const label = isSelected
1709
+ ? `${ANSI.inverse}${ANSI.bold}${padLine(labelText)}${ANSI.reset}`
1710
+ : ` ${choice.label}`
1711
+ if (!choice.hint) return [label]
1712
+ const hint = isSelected
1713
+ ? `${ANSI.inverse}${padLine(` ${choice.hint}`)}${ANSI.reset}`
1714
+ : `${ANSI.dim} ${choice.hint}${ANSI.reset}`
1715
+ return [label, hint]
1716
+ }),
1717
+ '',
1718
+ `${ANSI.dim}Use ↑/↓ to move, Enter to select, Ctrl+C to cancel.${ANSI.reset}`,
1719
+ ]
1720
+ renderedLines = lines.length
1721
+ process.stdout.write(`${lines.join('\n')}\n`)
1722
+ }
1723
+
1724
+ return new Promise((resolvePromise, reject) => {
1725
+ const cleanup = () => {
1726
+ process.stdin.off('keypress', onKeypress)
1727
+ if (process.stdin.setRawMode) process.stdin.setRawMode(Boolean(wasRaw))
1728
+ process.stdout.write('\x1b[?25h')
1729
+ }
1730
+ const onKeypress = (_str, key = {}) => {
1731
+ if (key.ctrl && key.name === 'c') {
1732
+ cleanup()
1733
+ process.stdout.write('\n')
1734
+ reject(new Error('Cancelled.'))
1735
+ return
1736
+ }
1737
+ if (key.name === 'up' || key.name === 'k') {
1738
+ selected = (selected - 1 + choices.length) % choices.length
1739
+ render()
1740
+ return
1741
+ }
1742
+ if (key.name === 'down' || key.name === 'j') {
1743
+ selected = (selected + 1) % choices.length
1744
+ render()
1745
+ return
1746
+ }
1747
+ if (key.name === 'return' || key.name === 'enter') {
1748
+ const choice = choices[selected]
1749
+ cleanup()
1750
+ process.stdout.write('\n')
1751
+ resolvePromise(choice)
1752
+ }
1753
+ }
1754
+
1755
+ process.stdout.write('\x1b[?25l')
1756
+ render()
1757
+ process.stdin.on('keypress', onKeypress)
1758
+ })
1759
+ }
1760
+
1761
+ function printInteractiveSummary({
1762
+ apiUrl,
1763
+ root,
1764
+ sourceName,
1765
+ site,
1766
+ hasGeneratedBaseline,
1767
+ generatedDiffSummary,
1768
+ hasSourceCheckBaseline,
1769
+ sourceCheckDiffSummary,
1770
+ observedLastJob,
1771
+ }) {
1772
+ const activeDiff = generatedDiffSummary || sourceCheckDiffSummary
1773
+ const changed = activeDiff
1774
+ ? activeDiff.added.length + activeDiff.changed.length + activeDiff.removed.length
1775
+ : null
1776
+ console.log('CodeSummary')
1777
+ console.log(` API: ${apiUrl}`)
1778
+ console.log(` Workspace: ${root}`)
1779
+ console.log(` Source: ${sourceName}`)
1780
+ console.log(` Site: ${site ? `${site.name} (${site.id})` : 'none yet; CodeSummary can create one'}`)
1781
+ if (!hasGeneratedBaseline && observedLastJob?.status === 'pending') {
1782
+ console.log(` State: generation queued; job ${observedLastJob.id}`)
1783
+ } else if (!hasGeneratedBaseline && observedLastJob?.status === 'processing') {
1784
+ console.log(` State: generation running; job ${observedLastJob.id}`)
1785
+ } else if (!hasGeneratedBaseline && observedLastJob?.status === 'failed') {
1786
+ console.log(` State: last generation failed; job ${observedLastJob.id}`)
1787
+ if (observedLastJob.error) console.log(` Error: ${observedLastJob.error}`)
1788
+ } else if (!hasGeneratedBaseline && !hasSourceCheckBaseline) {
1789
+ console.log(' State: fresh workspace; no previous CodeSummary submit baseline')
1790
+ } else if (!hasGeneratedBaseline) {
1791
+ if (changed === 0) {
1792
+ console.log(` State: setup checked; knowledge has not been generated yet (${activeDiff.unchanged} files unchanged since setup check)`)
1793
+ } else {
1794
+ console.log(
1795
+ ` State: setup checked, but ${changed} local change${changed === 1 ? '' : 's'} exist before first generation ` +
1796
+ `(${activeDiff.added.length} added, ${activeDiff.changed.length} changed, ${activeDiff.removed.length} removed)`
1797
+ )
1798
+ }
1799
+ } else if (changed === 0) {
1800
+ console.log(` State: up to date locally; ${generatedDiffSummary.unchanged} files unchanged since last generated knowledge`)
1801
+ } else {
1802
+ console.log(
1803
+ ` State: ${changed} local change${changed === 1 ? '' : 's'} since last generated knowledge ` +
1804
+ `(${generatedDiffSummary.added.length} added, ${generatedDiffSummary.changed.length} changed, ${generatedDiffSummary.removed.length} removed)`
1805
+ )
1806
+ }
1807
+ console.log('')
1808
+ }
1809
+
1810
+ async function ensureInteractiveAuth(options, root, config) {
1811
+ try {
1812
+ return await resolveAuth(options, config, root)
1813
+ } catch (error) {
1814
+ const choice = await promptChoice(
1815
+ 'You are not logged in to CodeSummary for this workspace.',
1816
+ [
1817
+ {
1818
+ label: 'Log in with CodeSummary OAuth',
1819
+ hint: 'Opens the browser and stores a refreshable CLI token.',
1820
+ },
1821
+ { label: 'Exit' },
1822
+ ]
1823
+ )
1824
+ if (choice.label === 'Exit') return null
1825
+ await login(options)
1826
+ return resolveAuth(options, readConfig(root), root)
1827
+ }
1828
+ }
1829
+
1830
+ async function interactive(options = {}) {
1831
+ const root = resolve(options.root || process.cwd())
1832
+ let config = readConfig(root)
1833
+ const apiUrl = resolveApiUrl(options, config)
1834
+ const auth = await ensureInteractiveAuth(options, root, config)
1835
+ if (!auth) return
1836
+
1837
+ config = auth.config
1838
+ const token = auth.token
1839
+ const sourceName = options.name || config.sourceName || basename(root)
1840
+ let linked = null
1841
+ try {
1842
+ linked = await fetchLinkedSite(apiUrl, token)
1843
+ if (linked?.site && !config.site) {
1844
+ config = { ...config, site: linked.site }
1845
+ writeConfig(config, root)
1846
+ }
1847
+ } catch {
1848
+ linked = null
1849
+ }
1850
+ const site = linked?.site || config.site || null
1851
+ const hasSite = Boolean(site)
1852
+ let observedLastJob = null
1853
+ if (config.lastSubmit?.generated && config.lastSubmit?.jobId) {
1854
+ try {
1855
+ const body = await fetchJobStatus(apiUrl, token, config.lastSubmit.jobId)
1856
+ observedLastJob = body.job || null
1857
+ if (maybePromoteCompletedGenerationBaseline(root, config, body)) {
1858
+ observedLastJob = null
1859
+ }
1860
+ } catch {
1861
+ observedLastJob = null
1862
+ }
1863
+ }
1864
+
1865
+ const baselinePath = join(root, CONFIG_DIR, LAST_SUBMITTED_MANIFEST_FILE)
1866
+ const sourceCheckPath = join(root, CONFIG_DIR, LAST_SOURCE_CHECK_MANIFEST_FILE)
1867
+ const hasGeneratedBaseline = existsSync(baselinePath)
1868
+ const hasSourceCheckBaseline = existsSync(sourceCheckPath)
1869
+ const currentManifest = await writeManifest(root)
1870
+ const generatedDiffSummary = hasGeneratedBaseline
1871
+ ? summarizeManifestDiff(readManifestFile(baselinePath), currentManifest)
1872
+ : null
1873
+ const sourceCheckDiffSummary = !hasGeneratedBaseline && hasSourceCheckBaseline
1874
+ ? summarizeManifestDiff(readManifestFile(sourceCheckPath), currentManifest)
1875
+ : null
1876
+ const activeDiffSummary = generatedDiffSummary || sourceCheckDiffSummary
1877
+ const changed = activeDiffSummary
1878
+ ? activeDiffSummary.added.length + activeDiffSummary.changed.length + activeDiffSummary.removed.length
1879
+ : null
1880
+
1881
+ printInteractiveSummary({
1882
+ apiUrl,
1883
+ root,
1884
+ sourceName,
1885
+ site,
1886
+ hasGeneratedBaseline,
1887
+ generatedDiffSummary,
1888
+ hasSourceCheckBaseline,
1889
+ sourceCheckDiffSummary,
1890
+ observedLastJob,
1891
+ })
1892
+
1893
+ const commonSubmitOptions = {
1894
+ ...options,
1895
+ root,
1896
+ name: sourceName,
1897
+ 'api-url': apiUrl,
1898
+ }
1899
+ const runInteractiveSubmit = async (submitOptions) => {
1900
+ const output = await submit({ ...submitOptions, silent: true })
1901
+ printSubmitSummary(output, { generate: submitOptions.generate, apiUrl })
1902
+ }
1903
+
1904
+ let choices
1905
+ if (!hasGeneratedBaseline && (observedLastJob?.status === 'pending' || observedLastJob?.status === 'processing')) {
1906
+ choices = [
1907
+ {
1908
+ label: 'Wait for current generation',
1909
+ hint: 'Follows the job that is already queued or running.',
1910
+ run: () => status({ ...options, root, 'api-url': apiUrl, 'job-id': observedLastJob.id, wait: true }),
1911
+ },
1912
+ {
1913
+ label: 'Check status once',
1914
+ hint: 'Shows the current job state without starting another run.',
1915
+ run: () => status({ ...options, root, 'api-url': apiUrl, 'job-id': observedLastJob.id }),
1916
+ },
1917
+ { label: 'Exit', run: async () => {} },
1918
+ ]
1919
+ } else if (!hasGeneratedBaseline) {
1920
+ choices = [
1921
+ {
1922
+ label: observedLastJob?.status === 'failed'
1923
+ ? 'Retry knowledge generation'
1924
+ : hasSite ? 'Generate first knowledge run' : 'Create a private docs site and generate knowledge',
1925
+ hint: hasSourceCheckBaseline
1926
+ ? 'Uses the checked setup and starts the first hosted generation job.'
1927
+ : 'Uploads this workspace and starts a hosted generation job.',
1928
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
1929
+ },
1930
+ {
1931
+ label: hasSite ? 'Advanced: verify setup only' : 'Advanced: create site without generation',
1932
+ hint: 'Checks OAuth, manifest, and source/site linking without retaining source or spending credits.',
1933
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
1934
+ },
1935
+ { label: 'Exit', run: async () => {} },
1936
+ ]
1937
+ } else if (changed === 0 && hasSite) {
1938
+ choices = [
1939
+ {
1940
+ label: 'Refresh knowledge anyway',
1941
+ hint: 'Starts a hosted generation job even though the local manifest matches the last submit.',
1942
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
1943
+ },
1944
+ {
1945
+ label: 'Advanced: verify setup only',
1946
+ hint: 'Checks the manifest and source/site link without retaining source or spending credits.',
1947
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
1948
+ },
1949
+ { label: 'Exit', run: async () => {} },
1950
+ ]
1951
+ } else if (changed === 0) {
1952
+ choices = [
1953
+ {
1954
+ label: 'Create a private docs site and generate knowledge',
1955
+ hint: 'The source manifest matches the last upload, but no site is linked locally.',
1956
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
1957
+ },
1958
+ {
1959
+ label: 'Advanced: create site without generation',
1960
+ hint: 'Checks source/site linking without retaining source or spending credits.',
1961
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
1962
+ },
1963
+ { label: 'Exit', run: async () => {} },
1964
+ ]
1965
+ } else {
1966
+ choices = [
1967
+ {
1968
+ label: 'Update knowledge',
1969
+ hint: 'Uploads local changes and starts a hosted generation job.',
1970
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
1971
+ },
1972
+ {
1973
+ label: 'Advanced: verify setup only',
1974
+ hint: 'Checks the manifest and source/site link without retaining source or spending credits.',
1975
+ run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
1976
+ },
1977
+ {
1978
+ label: 'Show detailed diff',
1979
+ hint: 'Prints added, changed, and removed files without uploading.',
1980
+ run: () => diff({ ...options, root }),
1981
+ },
1982
+ { label: 'Exit', run: async () => {} },
1983
+ ]
1984
+ }
1985
+
1986
+ const choice = await promptChoice('What do you want to do?', choices)
1987
+ await choice.run()
1988
+ }
1989
+
1990
+ async function main() {
1991
+ const rawArgs = process.argv.slice(2)
1992
+ if (rawArgs.length === 0) {
1993
+ if (isInteractiveTerminal()) {
1994
+ await interactive({})
1995
+ return
1996
+ }
1997
+ printHelp()
1998
+ return
1999
+ }
2000
+ if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs[0] === 'help') {
2001
+ printHelp()
2002
+ return
2003
+ }
2004
+ if (rawArgs[0] === '--version' || rawArgs[0] === '-v' || rawArgs[0] === 'version') {
2005
+ printVersion()
2006
+ return
2007
+ }
2008
+ const command = rawArgs[0] && !rawArgs[0].startsWith('--') ? rawArgs[0] : null
2009
+ const rest = command ? rawArgs.slice(1) : rawArgs
2010
+ const options = parseArgs(rest)
2011
+
2012
+ if (!command) {
2013
+ if (options.help || options.h) {
2014
+ printHelp()
2015
+ return
2016
+ }
2017
+ if (!isInteractiveTerminal()) {
2018
+ printHelp()
2019
+ return
2020
+ }
2021
+ await interactive(options)
2022
+ return
2023
+ }
2024
+
2025
+ if (command === 'new' || command === 'init') {
2026
+ await newWorkspace(options)
2027
+ return
2028
+ }
2029
+
2030
+ if (command === 'configure') {
2031
+ await configure(options)
2032
+ return
2033
+ }
2034
+
2035
+ if (command === 'config') {
2036
+ await configCommand(options)
2037
+ return
2038
+ }
2039
+
2040
+ if (command === 'update') {
2041
+ printUpdateInstructions()
2042
+ return
2043
+ }
2044
+
2045
+ if (command === 'link') {
2046
+ await link(options)
2047
+ return
2048
+ }
2049
+
2050
+ if (command === 'login') {
2051
+ await login(options)
2052
+ return
2053
+ }
2054
+
2055
+ if (command === 'whoami') {
2056
+ await whoami(options)
2057
+ return
2058
+ }
2059
+
2060
+ if (command === 'logout') {
2061
+ await logout(options)
2062
+ return
2063
+ }
2064
+
2065
+ if (command === 'manifest') {
2066
+ const root = resolve(options.root || process.cwd())
2067
+ const manifest = await writeManifest(root)
2068
+ console.log(JSON.stringify({ path: join(root, CONFIG_DIR, MANIFEST_FILE), files: manifest.files.length }, null, 2))
2069
+ return
2070
+ }
2071
+
2072
+ if (command === 'diff') {
2073
+ await diff(options)
2074
+ return
2075
+ }
2076
+
2077
+ if (command === 'export') {
2078
+ await exportBundle(options)
2079
+ return
2080
+ }
2081
+
2082
+ if (command === 'generate') {
2083
+ await generateLocal(options)
2084
+ return
2085
+ }
2086
+
2087
+ if (command === 'submit') {
2088
+ await submit(options)
2089
+ return
2090
+ }
2091
+
2092
+ if (command === 'status') {
2093
+ await status(options)
2094
+ return
2095
+ }
2096
+
2097
+ throw new Error(`Unknown command: ${command}`)
2098
+ }
2099
+
2100
+ main().catch((error) => {
2101
+ console.error(error instanceof Error ? error.message : String(error))
2102
+ process.exit(1)
2103
+ })