@pruddiman/hem 0.0.1-beta-5671db0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agents/arbiter-agent.d.ts +72 -0
  3. package/dist/agents/arbiter-agent.js +149 -0
  4. package/dist/agents/architecture-agent.d.ts +148 -0
  5. package/dist/agents/architecture-agent.js +459 -0
  6. package/dist/agents/base-agent.d.ts +44 -0
  7. package/dist/agents/base-agent.js +57 -0
  8. package/dist/agents/crossref-agent.d.ts +140 -0
  9. package/dist/agents/crossref-agent.js +560 -0
  10. package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
  11. package/dist/agents/crossref-arbiter-agent.js +147 -0
  12. package/dist/agents/documentation-agent.d.ts +55 -0
  13. package/dist/agents/documentation-agent.js +159 -0
  14. package/dist/agents/exploration-agent.d.ts +58 -0
  15. package/dist/agents/exploration-agent.js +102 -0
  16. package/dist/agents/grouping-agent.d.ts +167 -0
  17. package/dist/agents/grouping-agent.js +557 -0
  18. package/dist/agents/index-agent.d.ts +86 -0
  19. package/dist/agents/index-agent.js +360 -0
  20. package/dist/agents/organization-agent.d.ts +144 -0
  21. package/dist/agents/organization-agent.js +607 -0
  22. package/dist/auth.d.ts +372 -0
  23. package/dist/auth.js +1072 -0
  24. package/dist/broadcast-mcp.d.ts +21 -0
  25. package/dist/broadcast-mcp.js +59 -0
  26. package/dist/changelog.d.ts +85 -0
  27. package/dist/changelog.js +223 -0
  28. package/dist/decision-queue.d.ts +173 -0
  29. package/dist/decision-queue.js +265 -0
  30. package/dist/diff-scope.d.ts +24 -0
  31. package/dist/diff-scope.js +28 -0
  32. package/dist/discovery.d.ts +54 -0
  33. package/dist/discovery.js +405 -0
  34. package/dist/grouping.d.ts +37 -0
  35. package/dist/grouping.js +343 -0
  36. package/dist/helpers/format.d.ts +5 -0
  37. package/dist/helpers/format.js +13 -0
  38. package/dist/helpers/index.d.ts +11 -0
  39. package/dist/helpers/index.js +11 -0
  40. package/dist/helpers/parsing.d.ts +52 -0
  41. package/dist/helpers/parsing.js +128 -0
  42. package/dist/helpers/paths.d.ts +41 -0
  43. package/dist/helpers/paths.js +67 -0
  44. package/dist/helpers/strings.d.ts +45 -0
  45. package/dist/helpers/strings.js +97 -0
  46. package/dist/index.d.ts +135 -0
  47. package/dist/index.js +1087 -0
  48. package/dist/merge-utils.d.ts +22 -0
  49. package/dist/merge-utils.js +34 -0
  50. package/dist/orchestrator.d.ts +194 -0
  51. package/dist/orchestrator.js +1169 -0
  52. package/dist/output.d.ts +106 -0
  53. package/dist/output.js +243 -0
  54. package/dist/progress.d.ts +228 -0
  55. package/dist/progress.js +644 -0
  56. package/dist/providers/copilot.d.ts +247 -0
  57. package/dist/providers/copilot.js +598 -0
  58. package/dist/providers/index.d.ts +15 -0
  59. package/dist/providers/index.js +12 -0
  60. package/dist/providers/opencode.d.ts +156 -0
  61. package/dist/providers/opencode.js +416 -0
  62. package/dist/providers/types.d.ts +156 -0
  63. package/dist/providers/types.js +16 -0
  64. package/dist/resources.d.ts +76 -0
  65. package/dist/resources.js +151 -0
  66. package/dist/search-index.d.ts +71 -0
  67. package/dist/search-index.js +187 -0
  68. package/dist/search-mcp.d.ts +25 -0
  69. package/dist/search-mcp.js +100 -0
  70. package/dist/server-utils.d.ts +56 -0
  71. package/dist/server-utils.js +135 -0
  72. package/dist/session.d.ts +227 -0
  73. package/dist/session.js +370 -0
  74. package/dist/types.d.ts +272 -0
  75. package/dist/types.js +5 -0
  76. package/dist/worktree.d.ts +82 -0
  77. package/dist/worktree.js +187 -0
  78. package/package.json +45 -0
package/dist/index.js ADDED
@@ -0,0 +1,1087 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point for Hem.
4
+ *
5
+ * Configures Commander.js with all options, the `auth` subcommand, and
6
+ * dispatches to either auth handling or the main generation pipeline.
7
+ *
8
+ * Pipeline (v2):
9
+ * 1. Parse CLI args
10
+ * 2. If auth subcommand -> dispatch and exit
11
+ * 3. If --api-key without --model -> exit with error
12
+ * 4. Load preferences
13
+ * 5. If --model flag -> override preferences
14
+ * 6. Start OpenCode server via createOpencode()
15
+ * 7. If --api-key -> inject via injectApiKey()
16
+ * 8. detectAuthState()
17
+ * 9. If no credentials and no model override -> handleFirstRun()
18
+ * 10. Pipeline: discovery -> grouping -> exploration -> generation
19
+ * -> organization -> crossref -> architecture -> TOC -> complete
20
+ *
21
+ * Reference: FR-001, FR-022a.
22
+ */
23
+ import { Command } from "commander";
24
+ import { createRequire } from "node:module";
25
+ import { resolve, join } from "node:path";
26
+ import { realpathSync } from "node:fs";
27
+ import { access, readFile } from "node:fs/promises";
28
+ import { execFileSync } from "node:child_process";
29
+ import fg from "fast-glob";
30
+ import { fileURLToPath } from "node:url";
31
+ import { loadPreferences, loadProjectConfig, saveProjectConfig, detectAuthState, injectApiKey, handleFirstRun, renderAndWait, handleAuthLogin, handleAuthList, handleAuthLogout, validateStoredModel, resolveActualModel, listProviderModels, INVALID_MODEL_WARNING, AuthExpiredError, INVALID_API_KEY_ERROR, ZenCatalogError, PROJECT_CONFIG_DIR, } from "./auth.js";
32
+ import { createOpencode } from "@opencode-ai/sdk";
33
+ import { findFreePort, trackServer, untrackServer, startWithRetry } from "./server-utils.js";
34
+ import { discoverFiles, detectProjectName } from "./discovery.js";
35
+ import { groupFiles } from "./grouping.js";
36
+ import { GroupingAgent } from "./agents/grouping-agent.js";
37
+ import { DocumentationAgent } from "./agents/documentation-agent.js";
38
+ import { ArchitectureAgent } from "./agents/architecture-agent.js";
39
+ import { IndexAgent } from "./agents/index-agent.js";
40
+ import { OrganizationAgent } from "./agents/organization-agent.js";
41
+ import { CrossRefAgent } from "./agents/crossref-agent.js";
42
+ import { ExplorationAgent } from "./agents/exploration-agent.js";
43
+ import { OpenCodeProvider } from "./providers/opencode.js";
44
+ import { CopilotProvider } from "./providers/copilot.js";
45
+ import { generateDocumentation, getExitCode } from "./orchestrator.js";
46
+ import { SearchIndex } from "./search-index.js";
47
+ import { computeMaxConcurrency, describeResourceLimits, LARGE_PROJECT_THRESHOLD, computeAgentsPerGroup } from "./resources.js";
48
+ // Re-export getExitCode so existing imports from index.ts continue to work.
49
+ export { getExitCode };
50
+ import { renderDashboard, ConfigPrompt } from "./progress.js";
51
+ import { generateTableOfContents, generateTocLinkList, replaceTocPlaceholder, writeTableOfContents, writeArchitectureOverview, scanDocFiles, ensureDestinationDir, removeEmptyDirs, } from "./output.js";
52
+ import { readLastSHA, computeChangedFiles, getCurrentSHA, detectChangedDocs, writeChangelogEntry, } from "./changelog.js";
53
+ import { scopeToChangedFiles } from "./diff-scope.js";
54
+ import { getRepoRoot, getCurrentBranch, generateWorktreeBranch, ensureGitignored, setupWorktree, commitWorktree, pushWorktree, cleanupWorktree, } from "./worktree.js";
55
+ import React from "react";
56
+ // ── Version ────────────────────────────────────────────────────────────
57
+ const require = createRequire(import.meta.url);
58
+ const { version } = require("../package.json");
59
+ /** Default (production) dependency bag. */
60
+ export const defaultDeps = {
61
+ createOpencode,
62
+ findFreePort,
63
+ startWithRetry,
64
+ loadPreferences,
65
+ detectAuthState,
66
+ injectApiKey,
67
+ handleFirstRun,
68
+ handleAuthLogin,
69
+ handleAuthList,
70
+ handleAuthLogout,
71
+ loadProjectConfig,
72
+ saveProjectConfig,
73
+ renderAndWait,
74
+ validateStoredModel,
75
+ resolveActualModel,
76
+ listProviderModels,
77
+ checkSourceExists: (path) => access(path),
78
+ discoverFiles,
79
+ detectProjectName,
80
+ groupFiles,
81
+ createProvider: async (config) => {
82
+ const pid = config.model.providerID;
83
+ if (pid === "github-copilot" || pid === "copilot") {
84
+ return CopilotProvider.create(config);
85
+ }
86
+ return OpenCodeProvider.create(config);
87
+ },
88
+ generateDocumentation,
89
+ renderDashboard,
90
+ generateTableOfContents,
91
+ writeTableOfContents,
92
+ writeArchitectureOverview,
93
+ scanDocFiles,
94
+ ensureDestinationDir,
95
+ removeEmptyDirs,
96
+ readLastSHA,
97
+ computeChangedFiles,
98
+ getCurrentSHA,
99
+ detectChangedDocs,
100
+ writeChangelogEntry,
101
+ scopeToChangedFiles,
102
+ };
103
+ // ── Search index helpers ────────────────────────────────────────────────
104
+ /**
105
+ * Build/update the FTS search index from `.md` files in `destinationPath`.
106
+ *
107
+ * Uses a two-pass strategy:
108
+ * 1. **Git-diff fast path**: if the destination is tracked by git, only
109
+ * re-index files that changed since HEAD (falls back to hash scan on error).
110
+ * 2. **Hash scan**: glob all `*.md` files, call `SearchIndex.upsertDoc` for
111
+ * each — the method is a no-op when the SHA-256 hash is unchanged.
112
+ * 3. **Deletion sweep**: remove index entries for paths no longer on disk.
113
+ */
114
+ async function updateSearchIndex(index, destinationPath, verbose) {
115
+ let mdFiles;
116
+ try {
117
+ mdFiles = await fg("**/*.md", {
118
+ cwd: destinationPath,
119
+ absolute: false,
120
+ onlyFiles: true,
121
+ dot: false,
122
+ });
123
+ }
124
+ catch {
125
+ return; // destination doesn't exist yet
126
+ }
127
+ if (mdFiles.length === 0)
128
+ return;
129
+ // Try git-diff fast path: only re-index files that changed since HEAD
130
+ let changedPaths = null;
131
+ try {
132
+ const output = execFileSync("git", ["-C", destinationPath, "diff", "--name-only", "HEAD", "--", "*.md"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
133
+ const changed = output.trim().split("\n").filter(Boolean);
134
+ // Only use git fast path when it returns a strict subset (otherwise full scan is equally cheap)
135
+ if (changed.length < mdFiles.length) {
136
+ changedPaths = new Set(changed);
137
+ }
138
+ }
139
+ catch {
140
+ // git unavailable or no HEAD — fall through to full hash scan
141
+ }
142
+ const toIndex = changedPaths ? mdFiles.filter((p) => changedPaths.has(p)) : mdFiles;
143
+ let indexed = 0;
144
+ let skipped = 0;
145
+ for (const relPath of toIndex) {
146
+ try {
147
+ const content = await readFile(join(destinationPath, relPath), "utf-8");
148
+ const wasUpdated = index.upsertDoc(relPath, content);
149
+ if (wasUpdated)
150
+ indexed++;
151
+ else
152
+ skipped++;
153
+ }
154
+ catch {
155
+ // file unreadable — skip
156
+ }
157
+ }
158
+ // Deletion sweep: remove index entries for docs no longer on disk
159
+ const currentPaths = new Set(mdFiles);
160
+ let removed = 0;
161
+ for (const [path] of index.getAllHashes()) {
162
+ if (!currentPaths.has(path)) {
163
+ index.removeDoc(path);
164
+ removed++;
165
+ }
166
+ }
167
+ if (verbose) {
168
+ verbose(`[search-index] Updated: ${indexed} indexed, ${skipped} unchanged, ${removed} removed` +
169
+ (changedPaths ? " (git-diff fast path)" : " (full hash scan)"));
170
+ }
171
+ }
172
+ /**
173
+ * Records source-file → doc-file mappings in the search index.
174
+ *
175
+ * For each group, finds doc files whose path shares a segment with the
176
+ * group's directory or file paths, then maps the group's source files
177
+ * to those docs.
178
+ *
179
+ * This is a best-effort heuristic; paths without clear overlap are skipped.
180
+ */
181
+ function buildSourceDocMappings(index, docFiles, groups, _destinationPath) {
182
+ for (const group of groups) {
183
+ const groupSegments = new Set([
184
+ group.id,
185
+ ...group.id.split(/[-_/]+/).filter((s) => s.length > 2),
186
+ ...group.files.flatMap((f) => f.path.split(/[/\\]+/)).filter((s) => s.length > 2),
187
+ ]);
188
+ const matchingDocs = docFiles.filter((docPath) => {
189
+ const docSegments = docPath.replace(/\.md$/, "").split(/[/\\]+/);
190
+ return docSegments.some((seg) => groupSegments.has(seg));
191
+ });
192
+ const sourcePaths = group.files.map((f) => f.path);
193
+ for (const docPath of matchingDocs) {
194
+ index.setSourceMappings(docPath, sourcePaths);
195
+ }
196
+ }
197
+ }
198
+ // ── Handlers ───────────────────────────────────────────────────────────
199
+ /**
200
+ * Handle the default generate command.
201
+ *
202
+ * Implements the full startup sequence and v2 pipeline:
203
+ * 1. Parse CLI flags, validate constraints.
204
+ * 2. Load preferences.
205
+ * 3. Resolve model.
206
+ * 4. Start OpenCode server for auth.
207
+ * 5. Inject API key if provided.
208
+ * 6. Detect auth state.
209
+ * 7. First-run flow if needed.
210
+ * 8. Resolve absolute paths.
211
+ * 9. Start Ink dashboard.
212
+ * 10. Pipeline:
213
+ * a. Discovery
214
+ * b. Start orchestrator
215
+ * c. Grouping (LLM + heuristic fallback)
216
+ * d. Exploration (parallel)
217
+ * e. Documentation (parallel — agents write files directly)
218
+ * f. Organization (post-processing)
219
+ * g. Cross-references (post-processing)
220
+ * h. Architecture overview (post-processing)
221
+ * i. TOC generation (programmatic, scans disk)
222
+ * 11. Exit with appropriate exit code.
223
+ *
224
+ * @param opts - Parsed CLI flags from Commander.
225
+ * @param deps - Injectable dependencies.
226
+ * @returns The resolved `CLIOptions`, or `null` if the user exited early.
227
+ */
228
+ export async function handleGenerate(opts, deps = defaultDeps) {
229
+ // ── Step 1: Validate --api-key requires --model ────────────────────
230
+ if (opts.apiKey && !opts.model) {
231
+ process.stderr.write("--api-key requires --model to specify which provider to use.\n" +
232
+ " Example: npx @pruddiman/hem --model anthropic/claude-sonnet-4 --api-key sk-ant-xxx\n");
233
+ process.exitCode = 2;
234
+ return null;
235
+ }
236
+ const cliOptions = {
237
+ source: opts.source,
238
+ destination: opts.destination,
239
+ files: opts.files,
240
+ concurrency: opts.concurrency,
241
+ model: opts.model,
242
+ apiKey: opts.apiKey,
243
+ name: opts.name,
244
+ verbose: opts.verbose ?? false,
245
+ full: opts.full ?? false,
246
+ worktree: opts.worktree ?? false,
247
+ command: "generate",
248
+ authAction: undefined,
249
+ authTarget: undefined,
250
+ };
251
+ // ── Step 1b: Exit if project not configured ────────────────────────
252
+ const projectConfig = await deps.loadProjectConfig();
253
+ if (!projectConfig && !cliOptions.model && !cliOptions.apiKey) {
254
+ process.stderr.write("This project has not been configured for Hem.\n" +
255
+ "Run `hem config` to select a provider and model for this project.\n");
256
+ process.exitCode = 2;
257
+ return null;
258
+ }
259
+ // ── Step 2: Load preferences ───────────────────────────────────────
260
+ const preferences = await deps.loadPreferences();
261
+ // ── Step 3: Resolve model (--model flag > project config > preferences) ─
262
+ let resolvedModel;
263
+ let modelFromPreferences = false;
264
+ if (cliOptions.model) {
265
+ const slashIndex = cliOptions.model.indexOf("/");
266
+ if (slashIndex > 0) {
267
+ resolvedModel = {
268
+ providerID: cliOptions.model.slice(0, slashIndex),
269
+ modelID: cliOptions.model.slice(slashIndex + 1),
270
+ };
271
+ }
272
+ else {
273
+ resolvedModel = { providerID: cliOptions.model, modelID: "default" };
274
+ }
275
+ }
276
+ else if (projectConfig?.model) {
277
+ resolvedModel = projectConfig.model;
278
+ }
279
+ else if (preferences?.model) {
280
+ resolvedModel = preferences.model;
281
+ modelFromPreferences = true;
282
+ }
283
+ // ── Step 4: Start OpenCode server ──────────────────────────────────
284
+ const { client, server } = await deps.startWithRetry(async () => {
285
+ const port = await deps.findFreePort();
286
+ return deps.createOpencode({ port });
287
+ });
288
+ trackServer(() => server.close());
289
+ let authState;
290
+ let modelLabel = "default";
291
+ let providerLabel = "Default";
292
+ try {
293
+ // ── Step 5: Inject API key if provided ─────────────────────────────
294
+ if (cliOptions.apiKey && cliOptions.model) {
295
+ await deps.injectApiKey(client, cliOptions.model, cliOptions.apiKey);
296
+ }
297
+ // ── Step 6: Detect auth state ──────────────────────────────────────
298
+ authState = await deps.detectAuthState(client);
299
+ if (cliOptions.verbose) {
300
+ const providers = authState.connectedProviders.length > 0
301
+ ? authState.connectedProviders.map((p) => `${p.id} (${p.authMethod})`).join(", ")
302
+ : "none";
303
+ process.stderr.write(`[auth] Detected providers: ${providers}\n`);
304
+ process.stderr.write(`[auth] Has credentials: ${authState.hasCredentials}\n`);
305
+ }
306
+ // ── T045: Edge case — stored model no longer available ─────────────
307
+ if (modelFromPreferences && resolvedModel && preferences) {
308
+ const isValid = await deps.validateStoredModel(client, preferences);
309
+ if (!isValid) {
310
+ const warning = INVALID_MODEL_WARNING
311
+ .replace("{providerID}", resolvedModel.providerID)
312
+ .replace("{modelID}", resolvedModel.modelID);
313
+ process.stderr.write(warning);
314
+ resolvedModel = undefined;
315
+ }
316
+ }
317
+ // ── Step 7: First-run flow if needed ───────────────────────────────
318
+ if ((!authState.hasCredentials && !resolvedModel) ||
319
+ (modelFromPreferences && !resolvedModel)) {
320
+ let firstRunResult;
321
+ try {
322
+ firstRunResult = await deps.handleFirstRun(client);
323
+ }
324
+ catch (firstRunError) {
325
+ if (firstRunError instanceof ZenCatalogError) {
326
+ process.stderr.write(firstRunError.message);
327
+ }
328
+ else {
329
+ const errorMsg = firstRunError instanceof Error
330
+ ? firstRunError.message
331
+ : String(firstRunError);
332
+ process.stderr.write(errorMsg + "\n");
333
+ }
334
+ process.exitCode = 2;
335
+ return null;
336
+ }
337
+ if (firstRunResult === null) {
338
+ process.exitCode = 0;
339
+ return null;
340
+ }
341
+ resolvedModel = firstRunResult;
342
+ }
343
+ // ── Fallback model assignment ──────────────────────────────────────
344
+ if (!resolvedModel) {
345
+ resolvedModel = { providerID: "opencode", modelID: "default" };
346
+ }
347
+ // ── Resolve actual model/provider labels ────────────────────────────
348
+ const labels = await deps.resolveActualModel(client, resolvedModel);
349
+ modelLabel = labels.modelLabel;
350
+ providerLabel = labels.providerLabel;
351
+ }
352
+ finally {
353
+ server.close();
354
+ untrackServer();
355
+ }
356
+ if (cliOptions.verbose) {
357
+ const modelDesc = resolvedModel.modelID === "default"
358
+ ? `${resolvedModel.providerID} (server default)`
359
+ : `${resolvedModel.providerID}/${resolvedModel.modelID}`;
360
+ process.stderr.write(`[auth] Provider: ${resolvedModel.providerID}, Model: ${modelDesc}\n`);
361
+ }
362
+ // ── Worktree setup (--worktree flag) ────────────────────────────────
363
+ let worktreeState;
364
+ if (cliOptions.worktree) {
365
+ let repoRoot;
366
+ try {
367
+ repoRoot = getRepoRoot(process.cwd());
368
+ }
369
+ catch {
370
+ process.stderr.write("Error: --worktree requires a git repository. " +
371
+ "Run hem from inside a git repo.\n");
372
+ process.exitCode = 2;
373
+ return null;
374
+ }
375
+ const currentBranch = getCurrentBranch(repoRoot);
376
+ const branch = generateWorktreeBranch(currentBranch);
377
+ await ensureGitignored(repoRoot, ".hem/worktree/");
378
+ const wtPath = setupWorktree(repoRoot, branch);
379
+ worktreeState = { repoRoot, path: wtPath, branch };
380
+ if (cliOptions.verbose) {
381
+ process.stderr.write(`[worktree] Created branch ${branch} at ${wtPath}\n`);
382
+ }
383
+ }
384
+ // ── Step 8: Resolve absolute paths ─────────────────────────────────
385
+ const base = worktreeState?.path ?? process.cwd();
386
+ const absoluteSource = resolve(base, cliOptions.source);
387
+ const absoluteDestination = resolve(base, cliOptions.destination);
388
+ const projectName = cliOptions.name ?? await deps.detectProjectName(absoluteSource);
389
+ // Propagate resolved name so downstream code (orchestrator) never re-derives it.
390
+ cliOptions.name = projectName;
391
+ // ── T043: Edge case — source directory doesn't exist ─────────────
392
+ try {
393
+ await deps.checkSourceExists(absoluteSource);
394
+ }
395
+ catch {
396
+ process.stderr.write(`Source directory ${absoluteSource} does not exist.\n`);
397
+ process.exitCode = 2;
398
+ return null;
399
+ }
400
+ // ── Step 9: Start Ink dashboard ────────────────────────────────────
401
+ const startedAt = Date.now();
402
+ const initialState = {
403
+ phase: "discovery",
404
+ modelLabel,
405
+ providerLabel,
406
+ totalFiles: 0,
407
+ binaryFilesSkipped: 0,
408
+ totalGroups: 0,
409
+ featureGroups: 0,
410
+ layerGroups: 0,
411
+ totalPages: 0,
412
+ groupStatuses: [],
413
+ explorationStatuses: [],
414
+ explorationComplete: false,
415
+ indexFiles: [],
416
+ completedSessions: 0,
417
+ failedSessions: 0,
418
+ startedAt,
419
+ completedAt: undefined,
420
+ warnings: [],
421
+ };
422
+ const { updateState, waitUntilExit } = deps.renderDashboard(initialState, cliOptions.verbose);
423
+ try {
424
+ // ── Step 10a: Discovery phase ──────────────────────────────────────
425
+ const allFiles = await deps.discoverFiles(absoluteSource, cliOptions.files, absoluteDestination);
426
+ let textFiles = allFiles.filter((f) => !f.isBinary);
427
+ const binaryCount = allFiles.length - textFiles.length;
428
+ if (textFiles.length === 0) {
429
+ process.stderr.write(`No files found matching pattern "${cliOptions.files}" in ${absoluteSource}\n` +
430
+ " Check your --source and --files options.\n");
431
+ process.exitCode = 2;
432
+ await waitUntilExit();
433
+ return null;
434
+ }
435
+ if (cliOptions.verbose) {
436
+ process.stderr.write(`[discovery] Found ${allFiles.length} files (${binaryCount} binary skipped)\n`);
437
+ }
438
+ // ── Step 10a′: Diff-scoped incremental generation ──────────────────
439
+ // When a previous run's SHA exists in changelog.md (and --full is not
440
+ // set), narrow textFiles to only those that changed since the last run.
441
+ let prevSHA = null;
442
+ try {
443
+ prevSHA = await deps.readLastSHA(absoluteDestination);
444
+ }
445
+ catch {
446
+ // changelog.md unreadable — treat as first run
447
+ }
448
+ if (cliOptions.full) {
449
+ // Check if docs already exist to warn about unpredictable behavior
450
+ const existingDocs = await deps.scanDocFiles(absoluteDestination);
451
+ if (existingDocs.length > 0) {
452
+ process.stderr.write("Warning: --full forces complete re-generation. " +
453
+ "Behavior may be unpredictable when docs already exist.\n");
454
+ }
455
+ }
456
+ else if (prevSHA) {
457
+ try {
458
+ const changedPaths = deps.computeChangedFiles(absoluteSource, prevSHA);
459
+ if (changedPaths.length === 0) {
460
+ process.stderr.write("No source changes since last run — skipping generation.\n");
461
+ process.exitCode = 0;
462
+ await waitUntilExit();
463
+ return cliOptions;
464
+ }
465
+ textFiles = deps.scopeToChangedFiles(textFiles, changedPaths);
466
+ if (cliOptions.verbose) {
467
+ process.stderr.write(`[diff-scope] ${changedPaths.length} source files changed since ${prevSHA.slice(0, 8)}; ` +
468
+ `${textFiles.length} match discovery filter\n`);
469
+ }
470
+ // If all changed files were binary or outside the glob, skip
471
+ if (textFiles.length === 0) {
472
+ process.stderr.write("Changed files do not match the discovery filter — skipping generation.\n");
473
+ process.exitCode = 0;
474
+ await waitUntilExit();
475
+ return cliOptions;
476
+ }
477
+ }
478
+ catch (diffError) {
479
+ // Git failure (e.g., SHA no longer exists after rebase) — fall back to full scan
480
+ const msg = diffError instanceof Error ? diffError.message : String(diffError);
481
+ process.stderr.write(`Warning: Could not compute diff (${msg}). Falling back to full generation.\n`);
482
+ }
483
+ }
484
+ updateState({
485
+ phase: "grouping",
486
+ totalFiles: allFiles.length,
487
+ binaryFilesSkipped: binaryCount,
488
+ });
489
+ const verboseLog = cliOptions.verbose
490
+ ? (msg) => {
491
+ const ts = new Date().toISOString().slice(11, 23);
492
+ process.stderr.write(`[${ts}] ${msg}\n`);
493
+ }
494
+ : undefined;
495
+ // ── Step 10b: Start provider ─────────────────────────────────────
496
+ const hemDir = PROJECT_CONFIG_DIR;
497
+ const searchDbPath = join(hemDir, "search-index.db");
498
+ let provider;
499
+ try {
500
+ provider = await deps.createProvider({
501
+ model: resolvedModel,
502
+ destinationPath: absoluteDestination,
503
+ verbose: verboseLog,
504
+ searchDbPath,
505
+ });
506
+ }
507
+ catch (orchError) {
508
+ const errorMessage = orchError instanceof Error ? orchError.message : String(orchError);
509
+ process.stderr.write(errorMessage + "\n");
510
+ process.exitCode = 2;
511
+ await waitUntilExit();
512
+ return null;
513
+ }
514
+ // ── Step 10c: Grouping phase (LLM with heuristic fallback) ─────────
515
+ const groupingAgent = new GroupingAgent(provider, projectName);
516
+ let groups = await groupingAgent.run(textFiles, verboseLog, PROJECT_CONFIG_DIR);
517
+ if (!groups) {
518
+ if (cliOptions.verbose) {
519
+ verboseLog(`[grouping] LLM grouping unavailable, using heuristic fallback`);
520
+ }
521
+ groups = deps.groupFiles(textFiles);
522
+ }
523
+ const featureGroups = groups.filter((g) => g.type === "vertical").length;
524
+ const layerGroups = groups.filter((g) => g.type === "horizontal").length;
525
+ if (cliOptions.verbose) {
526
+ process.stderr.write(`[grouping] ${groups.length} groups (${featureGroups} feature, ${layerGroups} layer)\n`);
527
+ }
528
+ // Ensure destination directory exists before agents write to it
529
+ await deps.ensureDestinationDir(absoluteDestination);
530
+ // ── Open search index + update from existing docs ────────────────
531
+ // Build/refresh the FTS index over any existing .md files in the
532
+ // destination directory. This runs quickly on second+ runs because
533
+ // unchanged files are skipped via SHA-256 hash comparison.
534
+ const searchIndex = SearchIndex.open(searchDbPath);
535
+ await updateSearchIndex(searchIndex, absoluteDestination, verboseLog);
536
+ updateState({
537
+ phase: "generation",
538
+ totalGroups: groups.length,
539
+ featureGroups,
540
+ layerGroups,
541
+ totalPages: groups.length,
542
+ groupStatuses: groups.map((group) => ({
543
+ groupId: group.id,
544
+ label: group.label,
545
+ status: "queued",
546
+ })),
547
+ });
548
+ // ── Scaling detection ──────────────────────────────────────────────
549
+ const totalFileCount = textFiles.length;
550
+ const isLargeProject = totalFileCount > LARGE_PROJECT_THRESHOLD;
551
+ if (cliOptions.verbose) {
552
+ const agentsPerGroup = isLargeProject
553
+ ? computeAgentsPerGroup(totalFileCount)
554
+ : 1;
555
+ const maxConcurrency = computeMaxConcurrency(cliOptions.concurrency);
556
+ process.stderr.write(`[scaling] ${totalFileCount} source files, threshold=${LARGE_PROJECT_THRESHOLD}` +
557
+ ` → ${isLargeProject ? "multi-agent" : "single-agent"} mode\n`);
558
+ if (isLargeProject) {
559
+ process.stderr.write(`[scaling] Exploration & documentation: ${agentsPerGroup} agents per group, ` +
560
+ `${groups.length} groups, concurrency=${maxConcurrency}\n`);
561
+ process.stderr.write(`[scaling] Post-processing: organization (dynamic workers), ` +
562
+ `cross-references (parallel if >8 docs), ` +
563
+ `architecture & index (chunked if needed)\n`);
564
+ }
565
+ }
566
+ // ── Step 10d+e: Exploration + Documentation phases ──────────────────
567
+ let results;
568
+ let explorationFindings = [];
569
+ try {
570
+ const docAgent = new DocumentationAgent(provider);
571
+ const exploreAgent = new ExplorationAgent(provider);
572
+ const genResult = await deps.generateDocumentation(docAgent, groups, cliOptions, (partial) => updateState(partial), exploreAgent, searchIndex);
573
+ results = genResult.results;
574
+ explorationFindings = genResult.explorationFindings;
575
+ // Collect all doc files from disk (not from agent results, which may
576
+ // under-report when sessions time out but still write files).
577
+ let allDocFiles = await deps.scanDocFiles(absoluteDestination);
578
+ allDocFiles = allDocFiles.filter(f => f !== "index.md" && f !== "architecture.md" && f !== "changelog.md");
579
+ // ── Update index with newly written docs + source-doc mappings ───
580
+ // Re-index any docs written during this run (hash check skips unchanged).
581
+ // Then record source-file → doc-file mappings for each group.
582
+ await updateSearchIndex(searchIndex, absoluteDestination, verboseLog);
583
+ buildSourceDocMappings(searchIndex, allDocFiles, groups, absoluteDestination);
584
+ // ── Step 10f: Organization phase ──────────────────────────────────
585
+ try {
586
+ updateState({ phase: "organization" });
587
+ const orgAgent = new OrganizationAgent(provider);
588
+ await orgAgent.run({
589
+ projectName,
590
+ destinationPath: absoluteDestination,
591
+ allDocFiles,
592
+ }, verboseLog);
593
+ // Re-scan disk to discover what files exist after organization
594
+ allDocFiles = await deps.scanDocFiles(absoluteDestination);
595
+ allDocFiles = allDocFiles.filter(f => f !== "index.md" && f !== "architecture.md" && f !== "changelog.md");
596
+ }
597
+ catch (orgError) {
598
+ if (orgError instanceof AuthExpiredError) {
599
+ throw orgError;
600
+ }
601
+ const orgMessage = orgError instanceof Error ? orgError.message : String(orgError);
602
+ updateState({
603
+ warnings: [`Organization phase failed: ${orgMessage}`],
604
+ });
605
+ }
606
+ // ── Step 10g: Cross-references phase ──────────────────────────────
607
+ try {
608
+ updateState({ phase: "crossref" });
609
+ const xrefAgent = new CrossRefAgent(provider);
610
+ await xrefAgent.run({
611
+ projectName,
612
+ destinationPath: absoluteDestination,
613
+ allDocFiles,
614
+ }, verboseLog);
615
+ // Re-scan disk to discover what files exist after cross-referencing
616
+ allDocFiles = await deps.scanDocFiles(absoluteDestination);
617
+ allDocFiles = allDocFiles.filter(f => f !== "index.md" && f !== "architecture.md" && f !== "changelog.md");
618
+ }
619
+ catch (xrefError) {
620
+ if (xrefError instanceof AuthExpiredError) {
621
+ throw xrefError;
622
+ }
623
+ const xrefMessage = xrefError instanceof Error ? xrefError.message : String(xrefError);
624
+ updateState({
625
+ warnings: [`Cross-reference phase failed: ${xrefMessage}`],
626
+ });
627
+ }
628
+ // ── Steps 10h+10i: Architecture overview + Index page (parallel) ───
629
+ // Both agents write independent files (architecture.md and index.md)
630
+ // and neither reads the other's output, so they can run concurrently.
631
+ updateState({ phase: "architecture" });
632
+ const archAgent = new ArchitectureAgent(provider);
633
+ const indexAgent = new IndexAgent(provider);
634
+ // The index agent's context mirrors what it would see in the sequential
635
+ // pipeline (where arch ran first): include architecture.md in the list
636
+ // even though it may not be written yet when index starts.
637
+ const allDocFilesWithArch = [...allDocFiles, "architecture.md"];
638
+ const [archSettled, indexSettled] = await Promise.allSettled([
639
+ archAgent.run({
640
+ projectName,
641
+ sourceRoot: resolve(cliOptions.source),
642
+ destinationPath: absoluteDestination,
643
+ allFindings: explorationFindings,
644
+ allGroupSummaries: groups.map((group) => ({
645
+ id: group.id,
646
+ label: group.label,
647
+ files: group.files.map((f) => f.path),
648
+ })),
649
+ allDocFiles,
650
+ }, verboseLog),
651
+ indexAgent.run({
652
+ projectName,
653
+ destinationPath: absoluteDestination,
654
+ allDocFiles: allDocFilesWithArch,
655
+ allFindings: explorationFindings,
656
+ allGroupSummaries: groups.map((group) => ({
657
+ id: group.id,
658
+ label: group.label,
659
+ files: group.files.map((f) => f.path),
660
+ })),
661
+ }, verboseLog),
662
+ ]);
663
+ // Handle architecture result
664
+ if (archSettled.status === "rejected") {
665
+ if (archSettled.reason instanceof AuthExpiredError) {
666
+ throw archSettled.reason;
667
+ }
668
+ const msg = archSettled.reason instanceof Error
669
+ ? archSettled.reason.message
670
+ : String(archSettled.reason);
671
+ updateState({
672
+ warnings: [`Architecture overview generation failed: ${msg}`],
673
+ });
674
+ }
675
+ // Handle index result + TOC post-processing
676
+ {
677
+ updateState({ phase: "indexing" });
678
+ const indexFiles = [];
679
+ try {
680
+ // Scan disk AFTER both agents complete so the TOC includes architecture.md
681
+ const diskFiles = await deps.scanDocFiles(absoluteDestination);
682
+ let indexWritten = false;
683
+ if (indexSettled.status === "fulfilled") {
684
+ try {
685
+ // Read back what the agent wrote
686
+ const indexPath = join(absoluteDestination, "index.md");
687
+ const agentContent = await readFile(indexPath, "utf-8");
688
+ // Replace <!-- TOC --> placeholder with procedural link list
689
+ const tocLinkList = generateTocLinkList(projectName, diskFiles);
690
+ const finalContent = replaceTocPlaceholder(agentContent, tocLinkList);
691
+ await deps.writeTableOfContents(absoluteDestination, finalContent);
692
+ indexWritten = true;
693
+ }
694
+ catch (agentErr) {
695
+ if (agentErr instanceof AuthExpiredError) {
696
+ throw agentErr;
697
+ }
698
+ const msg = agentErr instanceof Error ? agentErr.message : String(agentErr);
699
+ if (verboseLog) {
700
+ verboseLog(`[index-agent] Failed to post-process index.md, falling back to procedural TOC: ${msg}`);
701
+ }
702
+ updateState({
703
+ warnings: [`Index page post-processing failed (using fallback): ${msg}`],
704
+ });
705
+ }
706
+ }
707
+ else {
708
+ if (indexSettled.reason instanceof AuthExpiredError) {
709
+ throw indexSettled.reason;
710
+ }
711
+ const msg = indexSettled.reason instanceof Error
712
+ ? indexSettled.reason.message
713
+ : String(indexSettled.reason);
714
+ if (verboseLog) {
715
+ verboseLog(`[index-agent] Failed, falling back to procedural TOC: ${msg}`);
716
+ }
717
+ updateState({
718
+ warnings: [`Index page agent failed (using fallback): ${msg}`],
719
+ });
720
+ }
721
+ // Fallback: procedural TOC generation
722
+ if (!indexWritten) {
723
+ const tocContent = deps.generateTableOfContents(projectName, diskFiles);
724
+ await deps.writeTableOfContents(absoluteDestination, tocContent);
725
+ }
726
+ indexFiles.push("index.md");
727
+ }
728
+ catch (tocErr) {
729
+ if (tocErr instanceof AuthExpiredError) {
730
+ throw tocErr;
731
+ }
732
+ const msg = tocErr instanceof Error ? tocErr.message : String(tocErr);
733
+ updateState({
734
+ warnings: [`Table of contents generation failed: ${msg}`],
735
+ });
736
+ }
737
+ if (indexFiles.length > 0) {
738
+ updateState({ indexFiles });
739
+ }
740
+ }
741
+ // ── Step 10j: Remove empty directories left behind by agents ─────
742
+ await deps.removeEmptyDirs(absoluteDestination);
743
+ }
744
+ finally {
745
+ try {
746
+ await provider.cleanup();
747
+ }
748
+ catch (cleanupErr) {
749
+ console.error("Provider cleanup failed:", cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr));
750
+ }
751
+ try {
752
+ searchIndex.close();
753
+ }
754
+ catch (closeErr) {
755
+ console.error("Search index close failed:", closeErr instanceof Error ? closeErr.message : String(closeErr));
756
+ }
757
+ }
758
+ // ── Step 10k: Complete phase ──────────────────────────────────────
759
+ // Final disk scan to get the actual page count (includes all agent output).
760
+ // This runs AFTER orchestrator shutdown — it's just a filesystem call.
761
+ const finalDocFiles = await deps.scanDocFiles(absoluteDestination);
762
+ // ── Step 10l: Write changelog entry ────────────────────────────────
763
+ // Detect which doc files were created/updated via git status, then
764
+ // record a changelog entry with the current HEAD SHA.
765
+ try {
766
+ const currentSHA = deps.getCurrentSHA(absoluteSource);
767
+ const changedDocs = deps.detectChangedDocs(absoluteDestination);
768
+ await deps.writeChangelogEntry(absoluteDestination, currentSHA, changedDocs, prevSHA === null);
769
+ if (cliOptions.verbose) {
770
+ process.stderr.write(`[changelog] Recorded entry at ${currentSHA.slice(0, 8)} ` +
771
+ `(${changedDocs.length} doc${changedDocs.length === 1 ? "" : "s"} changed)\n`);
772
+ }
773
+ }
774
+ catch (changelogError) {
775
+ // Changelog is best-effort — don't fail the pipeline
776
+ const msg = changelogError instanceof Error ? changelogError.message : String(changelogError);
777
+ if (cliOptions.verbose) {
778
+ process.stderr.write(`[changelog] Warning: Could not write changelog (${msg})\n`);
779
+ }
780
+ }
781
+ const totalPages = finalDocFiles.filter((f) => f !== "index.md" && f !== "changelog.md").length;
782
+ const completedAt = Date.now();
783
+ const failedResults = results.filter((r) => r.status === "failed");
784
+ const warnings = failedResults.map((r) => `Failed to generate: ${r.groupId}${r.error ? ` (${r.error})` : ""}`);
785
+ updateState({
786
+ phase: "complete",
787
+ completedAt,
788
+ totalPages,
789
+ completedSessions: results.length - failedResults.length,
790
+ failedSessions: failedResults.length,
791
+ warnings,
792
+ });
793
+ await waitUntilExit();
794
+ // ── Step 11: Set exit code ─────────────────────────────────────────
795
+ const exitCode = getExitCode(results);
796
+ if (exitCode !== 0) {
797
+ process.exitCode = exitCode;
798
+ }
799
+ // ── Worktree: commit, push, and clean up ───────────────────────────
800
+ if (worktreeState) {
801
+ try {
802
+ commitWorktree(worktreeState.path, "docs: generated by hem");
803
+ pushWorktree(worktreeState.path, worktreeState.branch);
804
+ process.stdout.write(`\n Branch pushed: ${worktreeState.branch}\n` +
805
+ ` Review and merge when ready.\n`);
806
+ }
807
+ catch (wtErr) {
808
+ const msg = wtErr instanceof Error ? wtErr.message : String(wtErr);
809
+ process.stderr.write(`Warning: worktree commit/push failed: ${msg}\n`);
810
+ }
811
+ finally {
812
+ cleanupWorktree(worktreeState.repoRoot, worktreeState.path);
813
+ }
814
+ }
815
+ return cliOptions;
816
+ }
817
+ catch (pipelineError) {
818
+ if (worktreeState) {
819
+ cleanupWorktree(worktreeState.repoRoot, worktreeState.path);
820
+ }
821
+ if (pipelineError instanceof AuthExpiredError && cliOptions.apiKey) {
822
+ const providerID = cliOptions.model
823
+ ? cliOptions.model.split("/")[0]
824
+ : pipelineError.providerName;
825
+ const message = INVALID_API_KEY_ERROR.replace("{providerID}", providerID);
826
+ process.stderr.write(message);
827
+ process.exitCode = 2;
828
+ await waitUntilExit();
829
+ return null;
830
+ }
831
+ if (pipelineError instanceof AuthExpiredError) {
832
+ process.stderr.write(pipelineError.message);
833
+ process.exitCode = 2;
834
+ await waitUntilExit();
835
+ return null;
836
+ }
837
+ const completedAt = Date.now();
838
+ const errorMessage = pipelineError instanceof Error
839
+ ? pipelineError.message
840
+ : String(pipelineError);
841
+ updateState({
842
+ phase: "error",
843
+ completedAt,
844
+ warnings: [errorMessage],
845
+ });
846
+ await waitUntilExit();
847
+ process.exitCode = 2;
848
+ return null;
849
+ }
850
+ }
851
+ /**
852
+ * Handle the `auth` subcommand.
853
+ */
854
+ export async function handleAuth(action, target, deps = defaultDeps) {
855
+ const validActions = ["login", "list", "logout"];
856
+ if (action && !validActions.includes(action)) {
857
+ process.stderr.write(`Unknown auth action: "${action}". Valid actions: login, list, logout\n`);
858
+ process.exitCode = 2;
859
+ return null;
860
+ }
861
+ const cliOptions = {
862
+ source: "./src",
863
+ destination: "./docs",
864
+ files: "**/*",
865
+ concurrency: computeMaxConcurrency(),
866
+ model: undefined,
867
+ apiKey: undefined,
868
+ name: undefined,
869
+ verbose: false,
870
+ full: false,
871
+ worktree: false,
872
+ command: "auth",
873
+ authAction: action ?? undefined,
874
+ authTarget: target,
875
+ };
876
+ const { client, server } = await deps.startWithRetry(async () => {
877
+ const port = await deps.findFreePort();
878
+ return deps.createOpencode({ port });
879
+ });
880
+ trackServer(() => server.close());
881
+ try {
882
+ const resolvedAction = cliOptions.authAction ?? "login";
883
+ switch (resolvedAction) {
884
+ case "login":
885
+ await deps.handleAuthLogin(client);
886
+ break;
887
+ case "list":
888
+ await deps.handleAuthList(client);
889
+ break;
890
+ case "logout":
891
+ await deps.handleAuthLogout(client, cliOptions.authTarget);
892
+ break;
893
+ }
894
+ }
895
+ finally {
896
+ server.close();
897
+ untrackServer();
898
+ }
899
+ return cliOptions;
900
+ }
901
+ /**
902
+ * Two-step interactive provider + model selection used by handleConfig.
903
+ * Step 1: Select a provider from the list of connected providers.
904
+ * Step 2: If the provider has models, select one; otherwise use "default".
905
+ * @internal
906
+ */
907
+ async function selectProviderAndModel(deps, client, providers) {
908
+ const providerOptions = providers.map((p) => ({
909
+ value: { providerID: p.id, modelID: "default" },
910
+ label: p.id === "opencode" ? "OpenCode" : p.name,
911
+ description: `(${p.authMethod})`,
912
+ }));
913
+ const providerSelection = await deps.renderAndWait((resolve) => React.createElement(ConfigPrompt, {
914
+ options: providerOptions,
915
+ onSelect: resolve,
916
+ title: "Select a provider:",
917
+ }));
918
+ const availableModels = await deps.listProviderModels(client, providerSelection.providerID);
919
+ if (availableModels.length === 0) {
920
+ return providerSelection;
921
+ }
922
+ const modelOptions = [
923
+ {
924
+ value: { providerID: providerSelection.providerID, modelID: "default" },
925
+ label: "Default",
926
+ description: "(let provider decide)",
927
+ },
928
+ ...availableModels.map((m) => ({
929
+ value: {
930
+ providerID: providerSelection.providerID,
931
+ // When a sub-provider is present (opencode meta-provider case), encode it
932
+ // in the modelID so OpenCodeProvider can route correctly. Without this,
933
+ // m.providerID ("github-copilot") would replace "opencode" as the top-level
934
+ // provider, causing CopilotProvider to be used instead of OpenCodeProvider.
935
+ modelID: m.providerID && m.providerID !== providerSelection.providerID
936
+ ? `${m.providerID}/${m.id}`
937
+ : m.id,
938
+ },
939
+ label: m.name,
940
+ })),
941
+ ];
942
+ return deps.renderAndWait((resolve) => React.createElement(ConfigPrompt, {
943
+ options: modelOptions,
944
+ onSelect: resolve,
945
+ title: "Select a model:",
946
+ }));
947
+ }
948
+ /** Print the confirmation message after saving config. @internal */
949
+ function printConfigSaved(model) {
950
+ console.log(`\n \u2713 Configuration saved to .hem/config.json\n`);
951
+ console.log(` Provider: ${model.providerID}`);
952
+ console.log(` Model: ${model.modelID}\n`);
953
+ }
954
+ /**
955
+ * Handle the `config` subcommand.
956
+ *
957
+ * Interactively prompts the user to select a provider/model and saves
958
+ * the configuration to `{cwd}/.hem/config.json`.
959
+ */
960
+ export async function handleConfig(deps = defaultDeps) {
961
+ const cliOptions = {
962
+ source: "./src",
963
+ destination: "./docs",
964
+ files: "**/*",
965
+ concurrency: computeMaxConcurrency(),
966
+ model: undefined,
967
+ apiKey: undefined,
968
+ name: undefined,
969
+ verbose: false,
970
+ full: false,
971
+ worktree: false,
972
+ command: "config",
973
+ authAction: undefined,
974
+ authTarget: undefined,
975
+ };
976
+ const { client, server } = await deps.startWithRetry(async () => {
977
+ const port = await deps.findFreePort();
978
+ return deps.createOpencode({ port });
979
+ });
980
+ trackServer(() => server.close());
981
+ try {
982
+ // Detect auth state to find connected providers.
983
+ const authState = await deps.detectAuthState(client);
984
+ // If no providers are connected, run auth first.
985
+ if (authState.connectedProviders.length === 0) {
986
+ const firstRunResult = await deps.handleFirstRun(client);
987
+ if (firstRunResult === null) {
988
+ // User chose to exit.
989
+ process.exitCode = 0;
990
+ return null;
991
+ }
992
+ // Re-detect auth state after first-run flow.
993
+ const updatedAuthState = await deps.detectAuthState(client);
994
+ if (updatedAuthState.connectedProviders.length === 0) {
995
+ process.stderr.write("No providers connected. Run `npx @pruddiman/hem auth login` to set up authentication.\n");
996
+ process.exitCode = 2;
997
+ return null;
998
+ }
999
+ // Present config prompt with updated providers.
1000
+ const updatedModel = await selectProviderAndModel(deps, client, updatedAuthState.connectedProviders);
1001
+ await deps.saveProjectConfig({ model: updatedModel });
1002
+ printConfigSaved(updatedModel);
1003
+ return cliOptions;
1004
+ }
1005
+ // Present config prompt with connected providers.
1006
+ const model = await selectProviderAndModel(deps, client, authState.connectedProviders);
1007
+ await deps.saveProjectConfig({ model });
1008
+ printConfigSaved(model);
1009
+ return cliOptions;
1010
+ }
1011
+ finally {
1012
+ server.close();
1013
+ untrackServer();
1014
+ }
1015
+ }
1016
+ // ── Program builder ────────────────────────────────────────────────────
1017
+ /**
1018
+ * Build and return a configured Commander program.
1019
+ */
1020
+ export function buildProgram(deps = defaultDeps) {
1021
+ const prog = new Command();
1022
+ prog
1023
+ .name("hem")
1024
+ .description("AI-powered documentation generator. Scans your source code and produces a complete, cross-referenced documentation site.")
1025
+ .version(version, "-V, --version")
1026
+ .option("-s, --source <path>", "Source directory to scan for files", "./src")
1027
+ .option("-d, --destination <path>", "Output directory for generated documentation", "./docs")
1028
+ .option("-f, --files <glob>", "Glob pattern to match source files", "**/*")
1029
+ .option("-c, --concurrency <n>", "Maximum parallel documentation sessions (auto-detected from system resources if omitted)", (v) => {
1030
+ const n = parseInt(v, 10);
1031
+ if (Number.isNaN(n) || n < 1) {
1032
+ throw new Error("concurrency must be a positive integer");
1033
+ }
1034
+ const effective = computeMaxConcurrency(n);
1035
+ if (effective < n) {
1036
+ process.stderr.write(`Warning: requested concurrency ${n} exceeds resource limit. ` +
1037
+ `Clamping to ${effective} (${describeResourceLimits(n)}).\n`);
1038
+ }
1039
+ return effective;
1040
+ }, computeMaxConcurrency())
1041
+ .option("-m, --model <id>", "Model to use (e.g., opencode/gpt-5-nano, anthropic/claude-sonnet-4). Overrides stored preferences.")
1042
+ .option("-k, --api-key <key>", "API key for the provider specified in --model. Bypasses OAuth and interactive prompts.")
1043
+ .option("-n, --name <name>", "Project name (used in page titles and headings). Auto-detected from package.json / Cargo.toml / etc. if omitted.")
1044
+ .option("-v, --verbose", "Show detailed logs and LLM output on stderr", false)
1045
+ .option("--full", "Force full re-generation of all docs (behavior may be unpredictable when docs already exist)", false)
1046
+ .option("--worktree", "Run in an isolated git worktree on a new branch, then commit and push for review", false)
1047
+ .action(async (opts) => {
1048
+ await handleGenerate(opts, deps);
1049
+ });
1050
+ prog
1051
+ .command("auth [action] [target]")
1052
+ .description("Manage authentication. Actions: login, list, logout. Use 'logout <id>' to remove a specific provider.")
1053
+ .action(async (action, target) => {
1054
+ await handleAuth(action, target, deps);
1055
+ });
1056
+ prog
1057
+ .command("config")
1058
+ .description("Configure Hem for this project. Interactively select a provider/model and save to .hem/config.json.")
1059
+ .action(async () => {
1060
+ await handleConfig(deps);
1061
+ });
1062
+ return prog;
1063
+ }
1064
+ // ── Parse & run ────────────────────────────────────────────────────────
1065
+ const scriptPath = fileURLToPath(import.meta.url);
1066
+ const isDirectRun = process.argv[1] !== undefined &&
1067
+ (() => {
1068
+ try {
1069
+ return realpathSync(process.argv[1]) === scriptPath;
1070
+ }
1071
+ catch {
1072
+ // Fallback for broken symlinks or permission errors
1073
+ return process.argv[1] === scriptPath;
1074
+ }
1075
+ })();
1076
+ if (isDirectRun) {
1077
+ const prog = buildProgram();
1078
+ prog
1079
+ .parseAsync(process.argv)
1080
+ .then(() => {
1081
+ process.exit(process.exitCode ?? 0);
1082
+ })
1083
+ .catch((err) => {
1084
+ console.error(err instanceof Error ? err.message : String(err));
1085
+ process.exit(1);
1086
+ });
1087
+ }