@pruddiman/hem 0.0.1-beta-1aff12a → 0.0.1-beta-1ec07ab

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.
@@ -14,7 +14,7 @@
14
14
  * - Low-level session ops (`promptAsync`, `session.abort`, `event.subscribe`)
15
15
  * are accessed via `this.provider.session.*` and `this.provider.event.*`.
16
16
  */
17
- import { isAuthExpired, AuthExpiredError } from "../auth.js";
17
+ import { wrapAuthError } from "../auth.js";
18
18
  // ── Base Agent ──────────────────────────────────────────────────────────
19
19
  /**
20
20
  * Abstract base class providing shared session lifecycle management
@@ -44,14 +44,6 @@ export class BaseAgent {
44
44
  * @throws {Error} If creation fails for any other reason.
45
45
  */
46
46
  async createSession(_title) {
47
- try {
48
- return await this.provider.createSession();
49
- }
50
- catch (err) {
51
- if (isAuthExpired(err)) {
52
- throw new AuthExpiredError("the configured provider", err);
53
- }
54
- throw err;
55
- }
47
+ return wrapAuthError(() => this.provider.createSession());
56
48
  }
57
49
  }
@@ -21,7 +21,7 @@
21
21
  * `BaseArbiter` factors out the lifecycle skeleton; subclasses provide
22
22
  * the four configuration values above.
23
23
  */
24
- import { isAuthExpired, AuthExpiredError } from "../auth.js";
24
+ import { wrapAuthError } from "../auth.js";
25
25
  import { BaseAgent } from "./base-agent.js";
26
26
  /**
27
27
  * Abstract base for arbiter coordinator agents. Concrete subclasses
@@ -51,21 +51,13 @@ export class BaseArbiter extends BaseAgent {
51
51
  if (verbose) {
52
52
  verbose(`[${this.tag}] session ${sessionId}`);
53
53
  }
54
- try {
55
- await this.provider.session.promptAsync({
56
- path: { id: sessionId },
57
- body: {
58
- parts: [{ type: "text", text: prompt }],
59
- agent: this.agentField,
60
- },
61
- });
62
- }
63
- catch (err) {
64
- if (isAuthExpired(err)) {
65
- throw new AuthExpiredError("the configured provider", err);
66
- }
67
- throw err;
68
- }
54
+ await wrapAuthError(() => this.provider.session.promptAsync({
55
+ path: { id: sessionId },
56
+ body: {
57
+ parts: [{ type: "text", text: prompt }],
58
+ agent: this.agentField,
59
+ },
60
+ }));
69
61
  if (verbose) {
70
62
  verbose(`[${this.tag}] initial prompt sent (listening for broadcasts)`);
71
63
  }
package/dist/auth.d.ts CHANGED
@@ -162,6 +162,14 @@ export declare class AuthExpiredError extends Error {
162
162
  readonly providerName: string;
163
163
  constructor(providerName: string, cause?: unknown);
164
164
  }
165
+ /**
166
+ * Run an async operation, converting auth-expiry failures into
167
+ * {@link AuthExpiredError}. Other errors propagate unchanged.
168
+ *
169
+ * Used by call sites that perform a single provider API call and want
170
+ * uniform auth-error handling.
171
+ */
172
+ export declare function wrapAuthError<T>(op: () => Promise<T>): Promise<T>;
165
173
  /**
166
174
  * Determine whether an error indicates expired or invalid authentication.
167
175
  *
@@ -364,7 +372,12 @@ export declare function handleAuthLogin(client: OpencodeClient, configDir?: stri
364
372
  *
365
373
  * Reference: FR-021, contracts/cli-interface.md lines 132-143.
366
374
  */
367
- export declare function handleAuthList(client: OpencodeClient, configDir?: string, writeFn?: (msg: string) => void): Promise<void>;
375
+ /** Per-backbone availability map injected into {@link handleAuthList}. */
376
+ export interface AuthListBackboneAvailability {
377
+ opencode: boolean;
378
+ copilot: boolean;
379
+ }
380
+ export declare function handleAuthList(client: OpencodeClient, configDir?: string, writeFn?: (msg: string) => void, availability?: AuthListBackboneAvailability): Promise<void>;
368
381
  /**
369
382
  * Handle `hem auth logout` — remove credentials.
370
383
  *
package/dist/auth.js CHANGED
@@ -430,6 +430,24 @@ export class AuthExpiredError extends Error {
430
430
  }
431
431
  }
432
432
  }
433
+ /**
434
+ * Run an async operation, converting auth-expiry failures into
435
+ * {@link AuthExpiredError}. Other errors propagate unchanged.
436
+ *
437
+ * Used by call sites that perform a single provider API call and want
438
+ * uniform auth-error handling.
439
+ */
440
+ export async function wrapAuthError(op) {
441
+ try {
442
+ return await op();
443
+ }
444
+ catch (err) {
445
+ if (isAuthExpired(err)) {
446
+ throw new AuthExpiredError("the configured provider", err);
447
+ }
448
+ throw err;
449
+ }
450
+ }
433
451
  /**
434
452
  * Determine whether an error indicates expired or invalid authentication.
435
453
  *
@@ -1007,29 +1025,7 @@ function formatAuthMethod(authMethod) {
1007
1025
  return "(env var)";
1008
1026
  }
1009
1027
  }
1010
- /**
1011
- * Handle `hem auth list` — show connected providers with status.
1012
- *
1013
- * Calls `client.config.providers()`, formats and prints connected providers
1014
- * with status (checkmark + name + auth method) per `contracts/cli-interface.md`
1015
- * lines 132-143, also shows the default model from preferences.
1016
- *
1017
- * Output format:
1018
- * ```
1019
- * Connected providers:
1020
- * ✓ anthropic Claude Pro/Max (OAuth)
1021
- * ✓ opencode Zen API Key
1022
- *
1023
- * Default model: anthropic/claude-sonnet-4
1024
- * ```
1025
- *
1026
- * @param client — OpenCode SDK client instance.
1027
- * @param configDir — Override for the config directory (used in tests).
1028
- * @param writeFn — Override for the output function (used in tests).
1029
- *
1030
- * Reference: FR-021, contracts/cli-interface.md lines 132-143.
1031
- */
1032
- export async function handleAuthList(client, configDir, writeFn = (msg) => process.stdout.write(msg)) {
1028
+ export async function handleAuthList(client, configDir, writeFn = (msg) => process.stdout.write(msg), availability) {
1033
1029
  // Query the SDK for connected providers.
1034
1030
  const result = await client.config.providers();
1035
1031
  const sdkProviders = result.data?.providers ?? [];
@@ -1057,7 +1053,17 @@ export async function handleAuthList(client, configDir, writeFn = (msg) => proce
1057
1053
  for (const provider of connectedProviders) {
1058
1054
  const padding = " ".repeat(Math.max(1, maxIdLen - provider.id.length + 4));
1059
1055
  const method = formatAuthMethod(provider.authMethod);
1060
- writeFn(` \u2713 ${provider.id}${padding}${provider.name} ${method}\n`);
1056
+ // Annotate copilot-backed providers as ✗ when the copilot CLI is
1057
+ // missing so the user knows the credential alone won't get them
1058
+ // through generation. Opencode-backed providers are always reachable
1059
+ // here because handleAuthList only runs when opencode is present.
1060
+ const isCopilot = provider.id === "github-copilot" || provider.id === "copilot";
1061
+ const reachable = !isCopilot || availability?.copilot !== false;
1062
+ const icon = reachable ? "\u2713" : "\u2717";
1063
+ const suffix = reachable
1064
+ ? ""
1065
+ : " (copilot CLI not installed — npm install -g @github/copilot)";
1066
+ writeFn(` ${icon} ${provider.id}${padding}${provider.name} ${method}${suffix}\n`);
1061
1067
  }
1062
1068
  }
1063
1069
  writeFn("\n");
package/dist/grouping.js CHANGED
@@ -66,7 +66,9 @@ export function matchDocFolder(filePath, priors) {
66
66
  if (priors.length === 0)
67
67
  return null;
68
68
  const segments = filePath.split("/").map((s) => s.toLowerCase());
69
- const fileStem = extractStem(segments[segments.length - 1] ?? "");
69
+ const lastSegment = segments[segments.length - 1] ?? "";
70
+ const dotIndex = lastSegment.indexOf(".");
71
+ const fileStem = (dotIndex === -1 ? lastSegment : lastSegment.substring(0, dotIndex)).toLowerCase();
70
72
  // Prefer the prior with the longest key match to avoid `auth` stealing
71
73
  // files that should go to `authentication`.
72
74
  let best = null;
@@ -90,10 +92,6 @@ export function normalizeFolderKey(name) {
90
92
  .replace(/-+/g, "-")
91
93
  .replace(/^-|-$/g, "");
92
94
  }
93
- function extractStem(basename) {
94
- const dotIndex = basename.indexOf(".");
95
- return (dotIndex === -1 ? basename : basename.substring(0, dotIndex)).toLowerCase();
96
- }
97
95
  /**
98
96
  * Scoring:
99
97
  * - 3 for a directory-segment match (strongest signal).
@@ -244,17 +242,6 @@ export function commonDirectory(files) {
244
242
  }
245
243
  return common.length === 0 ? "." : common.join("/");
246
244
  }
247
- /**
248
- * Returns the "name" portion of the file without known layer suffixes
249
- * and without the actual file extension.
250
- * E.g. `user.controller.ts` → the layer suffix is `.controller` → stem `user`.
251
- * `helpers.ts` → no layer suffix → stem `helpers`.
252
- */
253
- function fileNameWithoutExtension(relativePath) {
254
- const base = relativePath.split("/").pop() ?? "";
255
- const lastDot = base.lastIndexOf(".");
256
- return lastDot === -1 ? base : base.substring(0, lastDot);
257
- }
258
245
  /**
259
246
  * Detects the architectural layer for a file by checking its name
260
247
  * against known suffixes.
@@ -262,7 +249,9 @@ function fileNameWithoutExtension(relativePath) {
262
249
  * @returns The layer label (e.g., "Controllers") or `undefined`.
263
250
  */
264
251
  function detectLayer(relativePath) {
265
- const nameNoExt = fileNameWithoutExtension(relativePath).toLowerCase();
252
+ const base = relativePath.split("/").pop() ?? "";
253
+ const lastDot = base.lastIndexOf(".");
254
+ const nameNoExt = (lastDot === -1 ? base : base.substring(0, lastDot)).toLowerCase();
266
255
  for (const { suffix, label } of LAYER_SUFFIXES) {
267
256
  if (nameNoExt.endsWith(suffix)) {
268
257
  return label;
@@ -1,20 +1,6 @@
1
1
  /**
2
2
  * Text extraction and parsing utilities for Hem.
3
- *
4
- * Provides helpers for extracting structured content (Markdown, JSON,
5
- * file lists, concepts) from LLM response text.
6
3
  */
7
- import type { MessagePart } from "../session.js";
8
- /**
9
- * Extracts the Markdown content from the OpenCode prompt response.
10
- *
11
- * Concatenates all text parts from the response, stripping any wrapping
12
- * code fences if the LLM returned the content inside a fenced block.
13
- *
14
- * @param parts - The response parts from `promptAndWait()`.
15
- * @returns The extracted Markdown string.
16
- */
17
- export declare function extractMarkdown(parts: Array<MessagePart>): string;
18
4
  /**
19
5
  * Extracts a JSON string from an LLM response that may contain preamble text.
20
6
  *
@@ -31,22 +17,3 @@ export declare function extractMarkdown(parts: Array<MessagePart>): string;
31
17
  * @returns The extracted JSON substring (not yet parsed).
32
18
  */
33
19
  export declare function extractJSON(response: string): string;
34
- /**
35
- * Extracts `relatedFiles` from generated Markdown content.
36
- *
37
- * Looks for the "Source Files" or "Related Files" section and extracts file
38
- * paths from backtick-quoted entries (e.g., `` `src/user/controller.ts` ``).
39
- *
40
- * @param content - The generated Markdown content.
41
- * @returns Array of relative file paths found in the Source Files section.
42
- */
43
- export declare function parseRelatedFiles(content: string): string[];
44
- /**
45
- * Extracts key concept names from section content.
46
- *
47
- * Looks for backtick-quoted identifiers as a heuristic for concepts.
48
- *
49
- * @param content - The section body text.
50
- * @returns Array of unique concept strings.
51
- */
52
- export declare function extractConcepts(content: string): string[];
@@ -1,36 +1,6 @@
1
1
  /**
2
2
  * Text extraction and parsing utilities for Hem.
3
- *
4
- * Provides helpers for extracting structured content (Markdown, JSON,
5
- * file lists, concepts) from LLM response text.
6
3
  */
7
- /**
8
- * Extracts the Markdown content from the OpenCode prompt response.
9
- *
10
- * Concatenates all text parts from the response, stripping any wrapping
11
- * code fences if the LLM returned the content inside a fenced block.
12
- *
13
- * @param parts - The response parts from `promptAndWait()`.
14
- * @returns The extracted Markdown string.
15
- */
16
- export function extractMarkdown(parts) {
17
- const textParts = parts.filter((p) => p.type === "text" && typeof p.text === "string");
18
- let content = textParts.map((p) => p.text).join("\n");
19
- // Strip wrapping ```markdown ... ``` fences if present
20
- const fencePattern = /^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/;
21
- const match = content.match(fencePattern);
22
- if (match) {
23
- content = match[1];
24
- }
25
- // Strip preamble text before the first Markdown heading.
26
- // LLMs sometimes produce commentary like "Now I have a thorough
27
- // understanding of the codebase..." before the actual document.
28
- const h1Match = content.search(/^# .+/m);
29
- if (h1Match > 0) {
30
- content = content.slice(h1Match);
31
- }
32
- return content.trim();
33
- }
34
4
  /**
35
5
  * Extracts a JSON string from an LLM response that may contain preamble text.
36
6
  *
@@ -76,53 +46,3 @@ export function extractJSON(response) {
76
46
  // Strategy 3: return full string (caller's JSON.parse will validate)
77
47
  return response;
78
48
  }
79
- /**
80
- * Extracts `relatedFiles` from generated Markdown content.
81
- *
82
- * Looks for the "Source Files" or "Related Files" section and extracts file
83
- * paths from backtick-quoted entries (e.g., `` `src/user/controller.ts` ``).
84
- *
85
- * @param content - The generated Markdown content.
86
- * @returns Array of relative file paths found in the Source Files section.
87
- */
88
- export function parseRelatedFiles(content) {
89
- const files = [];
90
- const lines = content.split("\n");
91
- let inSection = false;
92
- for (const line of lines) {
93
- // Detect start of Source Files / Related Files section
94
- if (/^##\s+(Source|Related) Files/i.test(line)) {
95
- inSection = true;
96
- continue;
97
- }
98
- // End section at next heading
99
- if (inSection && /^##\s+/.test(line)) {
100
- break;
101
- }
102
- // Extract file paths from backtick-quoted list items
103
- if (inSection) {
104
- const match = line.match(/`([^`]+)`/);
105
- if (match) {
106
- files.push(match[1]);
107
- }
108
- }
109
- }
110
- return files;
111
- }
112
- /**
113
- * Extracts key concept names from section content.
114
- *
115
- * Looks for backtick-quoted identifiers as a heuristic for concepts.
116
- *
117
- * @param content - The section body text.
118
- * @returns Array of unique concept strings.
119
- */
120
- export function extractConcepts(content) {
121
- const matches = content.match(/`([^`]+)`/g);
122
- if (!matches)
123
- return [];
124
- const unique = new Set(matches
125
- .map((m) => m.slice(1, -1))
126
- .filter((c) => c.length > 0 && !/^\s|\s$/.test(c) && !c.includes(" ")));
127
- return Array.from(unique);
128
- }
@@ -29,13 +29,3 @@ export declare function isPathWithin(child: string, parent: string): boolean;
29
29
  * @throws If the resolved path falls outside the destination directory.
30
30
  */
31
31
  export declare function resolveOutputPath(destinationPath: string, relativePath: string): string;
32
- /**
33
- * Validates the relationship between destination and source paths.
34
- *
35
- * When the destination directory is inside the source directory, logs
36
- * a note that destination files will be excluded from scanning.
37
- *
38
- * @param destinationPath - Path to the destination directory.
39
- * @param sourcePath - Path to the source directory.
40
- */
41
- export declare function validateDestinationPath(destinationPath: string, sourcePath: string): void;
@@ -48,20 +48,3 @@ export function resolveOutputPath(destinationPath, relativePath) {
48
48
  }
49
49
  return resolved;
50
50
  }
51
- /**
52
- * Validates the relationship between destination and source paths.
53
- *
54
- * When the destination directory is inside the source directory, logs
55
- * a note that destination files will be excluded from scanning.
56
- *
57
- * @param destinationPath - Path to the destination directory.
58
- * @param sourcePath - Path to the source directory.
59
- */
60
- export function validateDestinationPath(destinationPath, sourcePath) {
61
- const absoluteDestination = resolve(destinationPath);
62
- const absoluteSource = resolve(sourcePath);
63
- if (isPathWithin(absoluteDestination, absoluteSource)) {
64
- console.log(`Note: Destination "${absoluteDestination}" is inside source "${absoluteSource}". ` +
65
- `Destination files will be excluded from scanning.`);
66
- }
67
- }
@@ -13,13 +13,6 @@
13
13
  * @returns kebab-cased string.
14
14
  */
15
15
  export declare function toKebabCase(input: string): string;
16
- /**
17
- * Converts a heading string to a URL-friendly slug.
18
- *
19
- * @param heading - The heading text.
20
- * @returns A kebab-case slug (e.g., "Related Files" → "related-files").
21
- */
22
- export declare function toSlug(heading: string): string;
23
16
  /**
24
17
  * Converts a directory name to a section heading.
25
18
  *
@@ -21,20 +21,6 @@ export function toKebabCase(input) {
21
21
  .replace(/-+/g, "-")
22
22
  .replace(/^-|-$/g, "");
23
23
  }
24
- /**
25
- * Converts a heading string to a URL-friendly slug.
26
- *
27
- * @param heading - The heading text.
28
- * @returns A kebab-case slug (e.g., "Related Files" → "related-files").
29
- */
30
- export function toSlug(heading) {
31
- return heading
32
- .toLowerCase()
33
- .replace(/[^a-z0-9\s-]/g, "")
34
- .replace(/\s+/g, "-")
35
- .replace(/-+/g, "-")
36
- .replace(/^-|-$/g, "");
37
- }
38
24
  /**
39
25
  * Converts a directory name to a section heading.
40
26
  *
@@ -45,19 +45,6 @@ export declare function buildImportGraph(files: FileInfo[]): Promise<ImportAnaly
45
45
  * isolated nodes appear as singleton components.
46
46
  */
47
47
  export declare function connectedComponents(universe: readonly string[], localEdges: Map<string, string[]>): string[][];
48
- /**
49
- * Compute fan-in (how many files import this file) and fan-out (how many
50
- * files this file imports) for every node in `universe`.
51
- */
52
- export declare function computeDegrees(universe: readonly string[], localEdges: Map<string, string[]>): Map<string, {
53
- fanIn: number;
54
- fanOut: number;
55
- }>;
56
- /**
57
- * Identify files participating in an import cycle. Uses iterative Tarjan's
58
- * SCC; any SCC with ≥2 members or a self-loop marks its members as cyclic.
59
- */
60
- export declare function nodesInCycles(localEdges: Map<string, string[]>): Set<string>;
61
48
  /**
62
49
  * Yield every import specifier found in `content` along with the 1-based
63
50
  * line number it appears on.
@@ -130,110 +130,6 @@ export function connectedComponents(universe, localEdges) {
130
130
  }
131
131
  return [...components.values()].map((c) => c.slice().sort((a, b) => a.localeCompare(b)));
132
132
  }
133
- /**
134
- * Compute fan-in (how many files import this file) and fan-out (how many
135
- * files this file imports) for every node in `universe`.
136
- */
137
- export function computeDegrees(universe, localEdges) {
138
- const degrees = new Map();
139
- for (const node of universe) {
140
- degrees.set(node, { fanIn: 0, fanOut: 0 });
141
- }
142
- for (const [from, tos] of localEdges) {
143
- const d = degrees.get(from);
144
- if (!d)
145
- continue;
146
- d.fanOut = tos.length;
147
- for (const to of tos) {
148
- const td = degrees.get(to);
149
- if (td)
150
- td.fanIn += 1;
151
- }
152
- }
153
- return degrees;
154
- }
155
- /**
156
- * Identify files participating in an import cycle. Uses iterative Tarjan's
157
- * SCC; any SCC with ≥2 members or a self-loop marks its members as cyclic.
158
- */
159
- export function nodesInCycles(localEdges) {
160
- const index = new Map();
161
- const lowlink = new Map();
162
- const onStack = new Set();
163
- const stack = [];
164
- const result = new Set();
165
- let counter = 0;
166
- const nodes = new Set();
167
- for (const [from, tos] of localEdges) {
168
- nodes.add(from);
169
- for (const to of tos)
170
- nodes.add(to);
171
- }
172
- const strongConnect = (start) => {
173
- const frames = [];
174
- index.set(start, counter);
175
- lowlink.set(start, counter);
176
- counter++;
177
- stack.push(start);
178
- onStack.add(start);
179
- frames.push({
180
- node: start,
181
- iter: (localEdges.get(start) ?? [])[Symbol.iterator](),
182
- });
183
- while (frames.length > 0) {
184
- const frame = frames[frames.length - 1];
185
- const next = frame.iter.next();
186
- if (next.done) {
187
- // Finished with frame.node — check if it's an SCC root.
188
- if (lowlink.get(frame.node) === index.get(frame.node)) {
189
- const component = [];
190
- let w;
191
- do {
192
- w = stack.pop();
193
- onStack.delete(w);
194
- component.push(w);
195
- } while (w !== frame.node);
196
- const neighbours = localEdges.get(frame.node) ?? [];
197
- const hasSelfLoop = neighbours.includes(frame.node);
198
- if (component.length >= 2 || hasSelfLoop) {
199
- for (const m of component)
200
- result.add(m);
201
- }
202
- }
203
- frames.pop();
204
- // Propagate lowlink to parent.
205
- if (frames.length > 0) {
206
- const parentFrame = frames[frames.length - 1];
207
- const pl = lowlink.get(parentFrame.node);
208
- const cl = lowlink.get(frame.node);
209
- if (cl < pl)
210
- lowlink.set(parentFrame.node, cl);
211
- }
212
- continue;
213
- }
214
- const w = next.value;
215
- if (!index.has(w)) {
216
- index.set(w, counter);
217
- lowlink.set(w, counter);
218
- counter++;
219
- stack.push(w);
220
- onStack.add(w);
221
- frames.push({ node: w, iter: (localEdges.get(w) ?? [])[Symbol.iterator]() });
222
- }
223
- else if (onStack.has(w)) {
224
- const cur = lowlink.get(frame.node);
225
- const wIndex = index.get(w);
226
- if (wIndex < cur)
227
- lowlink.set(frame.node, wIndex);
228
- }
229
- }
230
- };
231
- for (const node of nodes) {
232
- if (!index.has(node))
233
- strongConnect(node);
234
- }
235
- return result;
236
- }
237
133
  // ── Internal helpers ────────────────────────────────────────────────────
238
134
  /**
239
135
  * Yield every import specifier found in `content` along with the 1-based
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ import { findFreePort, startWithRetry } from "./server-utils.js";
28
28
  import { discoverFiles, detectProjectName } from "./discovery.js";
29
29
  import { groupFiles } from "./grouping.js";
30
30
  import type { Provider } from "./providers/types.js";
31
+ import { getBackboneAvailability } from "./providers/index.js";
31
32
  import { generateDocumentation, getExitCode } from "./orchestrator.js";
32
33
  export { getExitCode };
33
34
  import { renderDashboard } from "./progress.js";
@@ -78,15 +79,16 @@ export interface Dependencies {
78
79
  detectChangedDocs: typeof detectChangedDocs;
79
80
  writeChangelogEntry: typeof writeChangelogEntry;
80
81
  scopeToChangedFiles: typeof scopeToChangedFiles;
82
+ getBackboneAvailability: typeof getBackboneAvailability;
81
83
  }
82
84
  /**
83
85
  * Subset of {@link Dependencies} actually needed by `handleAuth`.
84
86
  * Narrower types document what each entry-point actually depends on
85
87
  * and let test mocks omit unrelated fields without TypeScript errors.
86
88
  */
87
- export type AuthDeps = Pick<Dependencies, "startWithRetry" | "findFreePort" | "createOpencode" | "handleAuthLogin" | "handleAuthList" | "handleAuthLogout">;
89
+ export type AuthDeps = Pick<Dependencies, "startWithRetry" | "findFreePort" | "createOpencode" | "handleAuthLogin" | "handleAuthList" | "handleAuthLogout" | "getBackboneAvailability">;
88
90
  /** Subset of {@link Dependencies} actually needed by `handleConfig`. */
89
- export type ConfigDeps = Pick<Dependencies, "startWithRetry" | "findFreePort" | "createOpencode" | "detectAuthState" | "handleFirstRun" | "saveProjectConfig" | "renderAndWait" | "listProviderModels">;
91
+ export type ConfigDeps = Pick<Dependencies, "startWithRetry" | "findFreePort" | "createOpencode" | "detectAuthState" | "handleFirstRun" | "saveProjectConfig" | "renderAndWait" | "listProviderModels" | "getBackboneAvailability">;
90
92
  /** Default (production) dependency bag. */
91
93
  export declare const defaultDeps: Dependencies;
92
94
  /**
@@ -132,13 +134,29 @@ export declare function handleGenerate(opts: {
132
134
  }, deps?: Dependencies): Promise<GenerateCLIOptions | null>;
133
135
  /**
134
136
  * Handle the `auth` subcommand.
137
+ *
138
+ * Gracefully degrades when the opencode CLI is missing: rather than
139
+ * crashing with `spawn opencode ENOENT`, prints a backbone-availability
140
+ * report so the user knows what to install. Login/logout require opencode
141
+ * today (the auth flows are opencode-managed); copilot uses GitHub
142
+ * authentication outside hem (`$GITHUB_TOKEN` or `gh auth login`).
135
143
  */
136
144
  export declare function handleAuth(action: string | undefined, target: string | undefined, deps?: AuthDeps): Promise<AuthCLIOptions | null>;
137
145
  /**
138
146
  * Handle the `config` subcommand.
139
147
  *
140
- * Interactively prompts the user to select a provider/model and saves
141
- * the configuration to `{cwd}/.hem/config.json`.
148
+ * Always opens with a backbone picker that lists every supported
149
+ * provider backbone (currently opencode and copilot) with a green \u2713
150
+ * "installed" or red \u2717 "not installed" indicator alongside an
151
+ * install hint. The user picks an installed backbone; uninstalled ones
152
+ * are visible but not selectable.
153
+ *
154
+ * - Pick `opencode` → start the opencode server, run the existing
155
+ * auth-detect + sub-provider/model selection flow.
156
+ * - Pick `copilot` → save `{providerID: "github-copilot", modelID: "default"}`
157
+ * directly (no opencode server needed).
158
+ * - No backbones installed → print install hints for all of them and
159
+ * exit non-zero.
142
160
  */
143
161
  export declare function handleConfig(deps?: ConfigDeps): Promise<ConfigCLIOptions | null>;
144
162
  /**
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ import { IndexAgent } from "./agents/index-agent.js";
41
41
  import { OrganizationAgent } from "./agents/organization-agent.js";
42
42
  import { CrossRefAgent } from "./agents/crossref-agent.js";
43
43
  import { ExplorationAgent } from "./agents/exploration-agent.js";
44
- import { createProviderFromConfig } from "./providers/index.js";
44
+ import { BACKBONES, backboneForProviderID, createProviderFromConfig, getBackboneAvailability, } from "./providers/index.js";
45
45
  import { generateDocumentation, getExitCode } from "./orchestrator.js";
46
46
  import { SearchIndex } from "./search-index.js";
47
47
  import { computeMaxConcurrency, describeResourceLimits, LARGE_PROJECT_THRESHOLD, computeAgentsPerGroup } from "./resources.js";
@@ -93,6 +93,7 @@ export const defaultDeps = {
93
93
  detectChangedDocs,
94
94
  writeChangelogEntry,
95
95
  scopeToChangedFiles,
96
+ getBackboneAvailability,
96
97
  };
97
98
  // ── Search index helpers ────────────────────────────────────────────────
98
99
  /**
@@ -272,6 +273,24 @@ export async function handleGenerate(opts, deps = defaultDeps) {
272
273
  resolvedModel = preferences.model;
273
274
  modelFromPreferences = true;
274
275
  }
276
+ // ── Step 3.5: Backbone pre-flight ──────────────────────────────────
277
+ //
278
+ // Before starting the opencode server (which spawns the `opencode`
279
+ // binary), make sure the backbone for the resolved model is reachable.
280
+ // If the user explicitly picked a model whose CLI is missing, surface
281
+ // a clean error with an install hint instead of `spawn ENOENT`.
282
+ const availability = deps.getBackboneAvailability();
283
+ const requiredBackbone = resolvedModel
284
+ ? backboneForProviderID(resolvedModel.providerID)
285
+ : "opencode";
286
+ if (!availability[requiredBackbone]) {
287
+ process.stderr.write(`\n \u2717 The ${requiredBackbone} CLI is required for the configured ` +
288
+ `model (${resolvedModel?.providerID ?? "default"}/` +
289
+ `${resolvedModel?.modelID ?? "default"}) but was not found on PATH.\n` +
290
+ ` Install: ${BACKBONES[requiredBackbone].installHint}\n\n`);
291
+ process.exitCode = 2;
292
+ return null;
293
+ }
275
294
  // ── Step 4: Start OpenCode server ──────────────────────────────────
276
295
  const { client, server } = await deps.startWithRetry(async () => {
277
296
  const port = await deps.findFreePort();
@@ -854,6 +873,12 @@ export async function handleGenerate(opts, deps = defaultDeps) {
854
873
  }
855
874
  /**
856
875
  * Handle the `auth` subcommand.
876
+ *
877
+ * Gracefully degrades when the opencode CLI is missing: rather than
878
+ * crashing with `spawn opencode ENOENT`, prints a backbone-availability
879
+ * report so the user knows what to install. Login/logout require opencode
880
+ * today (the auth flows are opencode-managed); copilot uses GitHub
881
+ * authentication outside hem (`$GITHUB_TOKEN` or `gh auth login`).
857
882
  */
858
883
  export async function handleAuth(action, target, deps = defaultDeps) {
859
884
  const validActions = ["login", "list", "logout"];
@@ -868,19 +893,40 @@ export async function handleAuth(action, target, deps = defaultDeps) {
868
893
  authAction: action ?? undefined,
869
894
  authTarget: target,
870
895
  };
896
+ const resolvedAction = cliOptions.authAction ?? "login";
897
+ const availability = deps.getBackboneAvailability();
898
+ // Without opencode, none of the SDK-driven auth flows can run. Surface a
899
+ // useful report instead of letting cross-spawn raise ENOENT.
900
+ if (!availability.opencode) {
901
+ if (resolvedAction === "list") {
902
+ process.stdout.write("\n Backbones:\n");
903
+ for (const [id, meta] of Object.entries(BACKBONES)) {
904
+ const ok = availability[id];
905
+ const icon = ok ? "\u2713" : "\u2717";
906
+ const tag = ok ? "installed" : `not installed (${meta.installHint})`;
907
+ process.stdout.write(` ${icon} ${id} ${tag}\n`);
908
+ }
909
+ process.stdout.write("\n");
910
+ }
911
+ else {
912
+ process.stderr.write(`\n \u2717 \`hem auth ${resolvedAction}\` requires the opencode CLI, which was not found on PATH.\n` +
913
+ ` Install: ${BACKBONES.opencode.installHint}\n\n`);
914
+ process.exitCode = 2;
915
+ }
916
+ return cliOptions;
917
+ }
871
918
  const { client, server } = await deps.startWithRetry(async () => {
872
919
  const port = await deps.findFreePort();
873
920
  return deps.createOpencode({ port });
874
921
  });
875
922
  trackServer(() => server.close());
876
923
  try {
877
- const resolvedAction = cliOptions.authAction ?? "login";
878
924
  switch (resolvedAction) {
879
925
  case "login":
880
926
  await deps.handleAuthLogin(client);
881
927
  break;
882
928
  case "list":
883
- await deps.handleAuthList(client);
929
+ await deps.handleAuthList(client, undefined, undefined, availability);
884
930
  break;
885
931
  case "logout":
886
932
  await deps.handleAuthLogout(client, cliOptions.authTarget);
@@ -899,12 +945,21 @@ export async function handleAuth(action, target, deps = defaultDeps) {
899
945
  * Step 2: If the provider has models, select one; otherwise use "default".
900
946
  * @internal
901
947
  */
902
- async function selectProviderAndModel(deps, client, providers) {
903
- const providerOptions = providers.map((p) => ({
904
- value: { providerID: p.id, modelID: "default" },
905
- label: p.id === "opencode" ? "OpenCode" : p.name,
906
- description: `(${p.authMethod})`,
907
- }));
948
+ async function selectProviderAndModel(deps, client, providers, availability) {
949
+ const providerOptions = providers.map((p) => {
950
+ const backbone = backboneForProviderID(p.id);
951
+ const reachable = availability[backbone];
952
+ const baseLabel = p.id === "opencode" ? "OpenCode" : p.name;
953
+ const label = reachable ? baseLabel : `${baseLabel} \u2717 (not installed)`;
954
+ const description = reachable
955
+ ? `(${p.authMethod})`
956
+ : `(${p.authMethod}) — install: ${BACKBONES[backbone].installHint}`;
957
+ return {
958
+ value: { providerID: p.id, modelID: "default" },
959
+ label,
960
+ description,
961
+ };
962
+ });
908
963
  const providerSelection = await deps.renderAndWait((resolve) => React.createElement(ConfigPrompt, {
909
964
  options: providerOptions,
910
965
  onSelect: resolve,
@@ -943,14 +998,78 @@ async function selectProviderAndModel(deps, client, providers) {
943
998
  /**
944
999
  * Handle the `config` subcommand.
945
1000
  *
946
- * Interactively prompts the user to select a provider/model and saves
947
- * the configuration to `{cwd}/.hem/config.json`.
1001
+ * Always opens with a backbone picker that lists every supported
1002
+ * provider backbone (currently opencode and copilot) with a green \u2713
1003
+ * "installed" or red \u2717 "not installed" indicator alongside an
1004
+ * install hint. The user picks an installed backbone; uninstalled ones
1005
+ * are visible but not selectable.
1006
+ *
1007
+ * - Pick `opencode` → start the opencode server, run the existing
1008
+ * auth-detect + sub-provider/model selection flow.
1009
+ * - Pick `copilot` → save `{providerID: "github-copilot", modelID: "default"}`
1010
+ * directly (no opencode server needed).
1011
+ * - No backbones installed → print install hints for all of them and
1012
+ * exit non-zero.
948
1013
  */
949
1014
  export async function handleConfig(deps = defaultDeps) {
950
1015
  const cliOptions = {
951
1016
  command: "config",
952
1017
  verbose: false,
953
1018
  };
1019
+ const availability = deps.getBackboneAvailability();
1020
+ if (!availability.opencode && !availability.copilot) {
1021
+ const lines = [
1022
+ "No model backbone is available. Install at least one of:",
1023
+ ...Object.entries(BACKBONES).map(([id, b]) => ` ${id}\t${b.installHint}`),
1024
+ "",
1025
+ ];
1026
+ process.stderr.write(lines.join("\n") + "\n");
1027
+ process.exitCode = 2;
1028
+ return null;
1029
+ }
1030
+ // Step 1 — top-level backbone picker. Always shows every supported
1031
+ // backbone with an availability indicator so the user can see what's
1032
+ // installed without having to inspect their PATH. Each option's
1033
+ // value.providerID is the canonical opencode-side identifier
1034
+ // (e.g. `github-copilot`, not the backbone slug `copilot`).
1035
+ const PROVIDER_FOR_BACKBONE = {
1036
+ opencode: "opencode",
1037
+ copilot: "github-copilot",
1038
+ };
1039
+ const LABEL_FOR_BACKBONE = {
1040
+ opencode: "OpenCode",
1041
+ copilot: "GitHub Copilot",
1042
+ };
1043
+ const backbonePickerOptions = Object.keys(BACKBONES).map((id) => {
1044
+ const meta = BACKBONES[id];
1045
+ const installed = availability[id];
1046
+ const icon = installed ? "\u2713" : "\u2717";
1047
+ const label = `${icon} ${LABEL_FOR_BACKBONE[id]} (${id})`;
1048
+ const description = installed
1049
+ ? "installed"
1050
+ : `not installed — install with: ${meta.installHint}`;
1051
+ return {
1052
+ value: { providerID: PROVIDER_FOR_BACKBONE[id], modelID: "default" },
1053
+ label,
1054
+ description,
1055
+ disabled: !installed,
1056
+ };
1057
+ });
1058
+ const backboneSelection = await deps.renderAndWait((resolve) => React.createElement(ConfigPrompt, {
1059
+ options: backbonePickerOptions,
1060
+ onSelect: resolve,
1061
+ title: "Select a provider backbone:",
1062
+ }));
1063
+ // Copilot path — no opencode server needed.
1064
+ if (backboneSelection.providerID === "github-copilot") {
1065
+ await deps.saveProjectConfig({ model: backboneSelection });
1066
+ console.log(`\n \u2713 Configuration saved to .hem/config.json\n`);
1067
+ console.log(` Provider: ${backboneSelection.providerID}`);
1068
+ console.log(` Model: ${backboneSelection.modelID} (copilot picks a default)\n`);
1069
+ return cliOptions;
1070
+ }
1071
+ // Opencode path — start the server and continue with the existing
1072
+ // auth-detect + sub-provider/model selection flow.
954
1073
  const { client, server } = await deps.startWithRetry(async () => {
955
1074
  const port = await deps.findFreePort();
956
1075
  return deps.createOpencode({ port });
@@ -976,7 +1095,7 @@ export async function handleConfig(deps = defaultDeps) {
976
1095
  }
977
1096
  }
978
1097
  // Present config prompt with connected providers.
979
- const model = await selectProviderAndModel(deps, client, authState.connectedProviders);
1098
+ const model = await selectProviderAndModel(deps, client, authState.connectedProviders, availability);
980
1099
  await deps.saveProjectConfig({ model });
981
1100
  console.log(`\n \u2713 Configuration saved to .hem/config.json\n`);
982
1101
  console.log(` Provider: ${model.providerID}`);
@@ -22,15 +22,17 @@ import type { GenerateCLIOptions, FileGroup, GenerationContext, GenerationResult
22
22
  import { DocumentationAgent } from "./agents/documentation-agent.js";
23
23
  import { ExplorationAgent } from "./agents/exploration-agent.js";
24
24
  import type { SearchIndex } from "./search-index.js";
25
- export { READ_ONLY_BASH, ORG_AGENT_BASH } from "./providers/index.js";
26
25
  /**
27
26
  * Total file count threshold above which multi-agent exploration is activated.
28
27
  * Below this threshold, the existing 1-agent-per-group behavior is used.
28
+ * Single source of truth lives in resources.ts; re-exported here for
29
+ * orchestrator-local callers and tests that reference it through this module.
29
30
  */
30
- export declare const LARGE_PROJECT_THRESHOLD = 200;
31
+ import { LARGE_PROJECT_THRESHOLD } from "./resources.js";
32
+ export { LARGE_PROJECT_THRESHOLD };
31
33
  /**
32
34
  * Hard ceiling on exploration sub-agents per group.
33
- * Actual count is computed by `computeAgentsPerGroup()`.
35
+ * Actual count is computed by `computeExplorationAgentsPerGroup()`.
34
36
  */
35
37
  export declare const MAX_EXPLORATION_AGENTS_PER_GROUP = 8;
36
38
  /**
@@ -38,8 +40,13 @@ export declare const MAX_EXPLORATION_AGENTS_PER_GROUP = 8;
38
40
  * total file count. Returns 1 (no splitting) when below the threshold.
39
41
  *
40
42
  * Scaling: starts at 4 agents at the threshold, grows to MAX at 2x the threshold.
43
+ *
44
+ * Distinct from {@link import("./resources.js").computeAgentsPerGroup} —
45
+ * resources.ts has a different formula clamped to the system resource ceiling
46
+ * and is used by the verbose CLI scaling log; this exploration-specific
47
+ * version drives orchestrator-internal partitioning.
41
48
  */
42
- export declare function computeAgentsPerGroup(totalFiles: number): number;
49
+ export declare function computeExplorationAgentsPerGroup(totalFiles: number): number;
43
50
  /**
44
51
  * Partition a group's files into N sub-groups for parallel exploration.
45
52
  * Uses round-robin assignment (files sorted by path for determinism).
@@ -23,17 +23,18 @@ import pLimit from "p-limit";
23
23
  import fg from "fast-glob";
24
24
  import { AuthExpiredError } from "./auth.js";
25
25
  import { computeMaxConcurrency, describeResourceLimits } from "./resources.js";
26
- // Re-export permission constants from provider module for backward compatibility.
27
- export { READ_ONLY_BASH, ORG_AGENT_BASH } from "./providers/index.js";
28
26
  // ── Multi-Agent Exploration Constants ────────────────────────────────
29
27
  /**
30
28
  * Total file count threshold above which multi-agent exploration is activated.
31
29
  * Below this threshold, the existing 1-agent-per-group behavior is used.
30
+ * Single source of truth lives in resources.ts; re-exported here for
31
+ * orchestrator-local callers and tests that reference it through this module.
32
32
  */
33
- export const LARGE_PROJECT_THRESHOLD = 200;
33
+ import { LARGE_PROJECT_THRESHOLD } from "./resources.js";
34
+ export { LARGE_PROJECT_THRESHOLD };
34
35
  /**
35
36
  * Hard ceiling on exploration sub-agents per group.
36
- * Actual count is computed by `computeAgentsPerGroup()`.
37
+ * Actual count is computed by `computeExplorationAgentsPerGroup()`.
37
38
  */
38
39
  export const MAX_EXPLORATION_AGENTS_PER_GROUP = 8;
39
40
  /**
@@ -46,8 +47,13 @@ const MIN_FILES_PER_AGENT = 3;
46
47
  * total file count. Returns 1 (no splitting) when below the threshold.
47
48
  *
48
49
  * Scaling: starts at 4 agents at the threshold, grows to MAX at 2x the threshold.
50
+ *
51
+ * Distinct from {@link import("./resources.js").computeAgentsPerGroup} —
52
+ * resources.ts has a different formula clamped to the system resource ceiling
53
+ * and is used by the verbose CLI scaling log; this exploration-specific
54
+ * version drives orchestrator-internal partitioning.
49
55
  */
50
- export function computeAgentsPerGroup(totalFiles) {
56
+ export function computeExplorationAgentsPerGroup(totalFiles) {
51
57
  if (totalFiles < LARGE_PROJECT_THRESHOLD)
52
58
  return 1;
53
59
  const ratio = totalFiles / LARGE_PROJECT_THRESHOLD;
@@ -394,7 +400,7 @@ export async function runExploration(explorationAgent, groups, options, onProgre
394
400
  }));
395
401
  // Compute total file count to determine if multi-agent exploration is needed
396
402
  const totalFiles = groups.reduce((sum, g) => sum + g.files.length, 0);
397
- const agentsPerGroup = computeAgentsPerGroup(totalFiles);
403
+ const agentsPerGroup = computeExplorationAgentsPerGroup(totalFiles);
398
404
  const isMultiAgent = agentsPerGroup > 1;
399
405
  if (verbose && isMultiAgent) {
400
406
  verbose(`[orchestrator] Large project detected: ${totalFiles} files → ${agentsPerGroup} agents per group`);
@@ -841,7 +847,7 @@ export async function generateDocumentation(agent, groups, options, onProgress,
841
847
  });
842
848
  // ── Multi-agent documentation detection ──────────────────────────
843
849
  const totalFiles = groups.reduce((sum, g) => sum + g.files.length, 0);
844
- const docAgentsPerGroup = computeAgentsPerGroup(totalFiles);
850
+ const docAgentsPerGroup = computeExplorationAgentsPerGroup(totalFiles);
845
851
  const isMultiAgentDoc = docAgentsPerGroup > 1;
846
852
  if (verbose) {
847
853
  verbose(`[orchestrator] Starting documentation: ${groups.length} groups, concurrency=${sharedConcurrency} (shared with exploration)` +
@@ -94,6 +94,15 @@ export interface ConfigOption {
94
94
  label: string;
95
95
  /** Optional description shown below the label. */
96
96
  description?: string;
97
+ /**
98
+ * When true, the option is rendered in red (with a leading red marker
99
+ * supplied by the label) and is **not selectable** — arrow-key
100
+ * navigation skips over it and Enter does nothing while it is the
101
+ * "logical" selection. Used for backbones whose CLI is not installed
102
+ * so the user sees what's missing without being able to pick a broken
103
+ * choice.
104
+ */
105
+ disabled?: boolean;
97
106
  }
98
107
  /** Props for the ConfigPrompt component. */
99
108
  export interface ConfigPromptProps {
package/dist/progress.js CHANGED
@@ -209,7 +209,15 @@ export function FreeModelPicker({ models, onSelect, }) {
209
209
  * following the same navigation pattern as AuthPrompt.
210
210
  */
211
211
  export function ConfigPrompt({ options, onSelect, title, maxVisible, }) {
212
- const [navState, setNavState] = useState({ selectedIndex: 0, viewportStart: 0 });
212
+ // Find the first selectable (non-disabled) option to use as the initial
213
+ // cursor position. If every option is disabled, fall back to 0 so the
214
+ // picker renders without crashing — Enter will simply be a no-op.
215
+ const firstSelectable = options.findIndex((o) => !o.disabled);
216
+ const initialIndex = firstSelectable === -1 ? 0 : firstSelectable;
217
+ const [navState, setNavState] = useState({
218
+ selectedIndex: initialIndex,
219
+ viewportStart: 0,
220
+ });
213
221
  const { selectedIndex, viewportStart } = navState;
214
222
  const { stdout } = useStdout();
215
223
  // Mirror selectedIndex into a ref so the ENTER branch below reads the latest
@@ -219,28 +227,42 @@ export function ConfigPrompt({ options, onSelect, title, maxVisible, }) {
219
227
  // Fixed overhead: header(2) + footer(2) + up-indicator(1) + down-indicator(1) = 6
220
228
  const terminalRows = stdout?.rows ?? 24;
221
229
  const maxVis = maxVisible ?? Math.max(3, Math.floor((terminalRows - 6) / 2));
230
+ // Skip disabled entries while navigating with arrow keys so the cursor
231
+ // never lands on an unselectable option.
232
+ const nextSelectable = (from, dir) => {
233
+ let i = from + dir;
234
+ while (i >= 0 && i < options.length) {
235
+ if (!options[i].disabled)
236
+ return i;
237
+ i += dir;
238
+ }
239
+ return null;
240
+ };
222
241
  // Use functional updater form so rapid key presses don't read stale state.
223
242
  useInput((input, key) => {
224
243
  if (key.upArrow) {
225
244
  setNavState((prev) => {
226
- if (prev.selectedIndex <= 0)
245
+ const next = nextSelectable(prev.selectedIndex, -1);
246
+ if (next === null)
227
247
  return prev;
228
- const next = prev.selectedIndex - 1;
229
248
  const newViewport = next < prev.viewportStart ? next : prev.viewportStart;
230
249
  return { selectedIndex: next, viewportStart: newViewport };
231
250
  });
232
251
  }
233
252
  else if (key.downArrow) {
234
253
  setNavState((prev) => {
235
- if (prev.selectedIndex >= options.length - 1)
254
+ const next = nextSelectable(prev.selectedIndex, 1);
255
+ if (next === null)
236
256
  return prev;
237
- const next = prev.selectedIndex + 1;
238
257
  const newViewport = next >= prev.viewportStart + maxVis ? next - maxVis + 1 : prev.viewportStart;
239
258
  return { selectedIndex: next, viewportStart: newViewport };
240
259
  });
241
260
  }
242
261
  else if (key.return && options.length > 0) {
243
- onSelect(options[selectedIndexRef.current].value);
262
+ const current = options[selectedIndexRef.current];
263
+ if (current && !current.disabled) {
264
+ onSelect(current.value);
265
+ }
244
266
  }
245
267
  });
246
268
  const visibleOptions = options.slice(viewportStart, viewportStart + maxVis);
@@ -250,7 +272,14 @@ export function ConfigPrompt({ options, onSelect, title, maxVisible, }) {
250
272
  const index = viewportStart + i;
251
273
  const isSelected = index === selectedIndex;
252
274
  const pointer = isSelected ? "\u276F" : " ";
253
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { children: _jsxs(Text, { color: isSelected ? "cyan" : undefined, bold: isSelected, children: [pointer, " ", option.label] }) }), option.description && (_jsx(Text, { children: _jsxs(Text, { dimColor: true, children: [" ", option.description] }) })), i < visibleOptions.length - 1 && _jsx(Text, { children: "" })] }, `${option.value.providerID}/${option.value.modelID}`));
275
+ // Disabled options always render dim red regardless of cursor
276
+ // position so they read as "informational, not selectable".
277
+ const labelColor = option.disabled
278
+ ? "red"
279
+ : isSelected
280
+ ? "cyan"
281
+ : undefined;
282
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { children: _jsxs(Text, { color: labelColor, bold: isSelected && !option.disabled, dimColor: option.disabled, children: [pointer, " ", option.label] }) }), option.description && (_jsx(Text, { children: _jsxs(Text, { dimColor: true, color: option.disabled ? "red" : undefined, children: [" ", option.description] }) })), i < visibleOptions.length - 1 && _jsx(Text, { children: "" })] }, `${option.value.providerID}/${option.value.modelID}`));
254
283
  }), hasBelow && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [" ", "\u2193 ", options.length - viewportStart - maxVis, " more below"] }) })), _jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [" ", "Use arrow keys to navigate, Enter to confirm."] }) })] }));
255
284
  }
256
285
  /** Ordered pipeline phases for display. */
@@ -171,7 +171,7 @@ export class CopilotProvider {
171
171
  this._config = config;
172
172
  this._factory = factory ?? (async (token) => {
173
173
  const { CopilotClient } = await import("@github/copilot-sdk");
174
- const copilot = new CopilotClient(token ? { githubToken: token } : {});
174
+ const copilot = new CopilotClient(token ? { gitHubToken: token } : {});
175
175
  await copilot.start();
176
176
  return {
177
177
  client: copilot,
@@ -9,9 +9,6 @@
9
9
  * functions. Adding a new provider only requires registering it here —
10
10
  * call sites use {@link createProviderFromConfig} and never branch on
11
11
  * provider IDs.
12
- *
13
- * Note: SessionClient, SessionTracker, CopilotSessionAdapter are no longer
14
- * exported — they are internal implementation details of the providers.
15
12
  */
16
13
  import type { Provider, ProviderConfig } from "./types.js";
17
14
  export type { Provider, ProviderConfig, SseEvent, } from "./types.js";
@@ -44,3 +41,39 @@ export declare const PROVIDER_REGISTRY: Readonly<Record<string, ProviderFactory>
44
41
  * `defaultDeps.createProvider` so provider selection lives in one place.
45
42
  */
46
43
  export declare function createProviderFromConfig(config: ProviderConfig): Promise<Provider>;
44
+ /**
45
+ * Backbone identifier — the underlying CLI that ultimately runs a model.
46
+ *
47
+ * Hem currently has two: `opencode` (covers most third-party model providers
48
+ * via the OpenCode server) and `copilot` (the @github/copilot CLI used by
49
+ * {@link CopilotProvider}).
50
+ */
51
+ export type Backbone = "opencode" | "copilot";
52
+ /**
53
+ * Map of backbone → `{ cli, installHint }`. The `cli` field is the bare
54
+ * binary name expected on `PATH`; `installHint` is the suggested install
55
+ * command surfaced when the CLI is missing.
56
+ */
57
+ export declare const BACKBONES: Readonly<Record<Backbone, {
58
+ cli: string;
59
+ installHint: string;
60
+ }>>;
61
+ /** Result of {@link getBackboneAvailability}: which backbones are usable. */
62
+ export type BackboneAvailability = Record<Backbone, boolean>;
63
+ /**
64
+ * Probe each known backbone CLI and return a presence map. Pure, cheap,
65
+ * synchronous — safe to call before any UI or server-startup decision so
66
+ * hem can degrade gracefully when, e.g., `opencode` is not installed but
67
+ * `copilot` is.
68
+ *
69
+ * @param check - Override for testing (defaults to {@link isCliAvailable}).
70
+ */
71
+ export declare function getBackboneAvailability(check?: (name: string) => boolean): BackboneAvailability;
72
+ /**
73
+ * Determine which backbone an opencode-style provider ID belongs to.
74
+ *
75
+ * Provider IDs in {@link PROVIDER_REGISTRY} (currently `github-copilot` and
76
+ * `copilot`) map to the copilot backbone; everything else flows through
77
+ * the opencode server.
78
+ */
79
+ export declare function backboneForProviderID(providerID: string): Backbone;
@@ -9,12 +9,10 @@
9
9
  * functions. Adding a new provider only requires registering it here —
10
10
  * call sites use {@link createProviderFromConfig} and never branch on
11
11
  * provider IDs.
12
- *
13
- * Note: SessionClient, SessionTracker, CopilotSessionAdapter are no longer
14
- * exported — they are internal implementation details of the providers.
15
12
  */
16
13
  import { OpenCodeProvider } from "./opencode.js";
17
14
  import { CopilotProvider } from "./copilot.js";
15
+ import { isCliAvailable } from "../server-utils.js";
18
16
  export { OpenCodeProvider, READ_ONLY_BASH, ORG_AGENT_BASH } from "./opencode.js";
19
17
  export { CopilotProvider } from "./copilot.js";
20
18
  /**
@@ -44,3 +42,42 @@ export function createProviderFromConfig(config) {
44
42
  const factory = PROVIDER_REGISTRY[config.model.providerID] ?? DEFAULT_PROVIDER_FACTORY;
45
43
  return factory(config);
46
44
  }
45
+ /**
46
+ * Map of backbone → `{ cli, installHint }`. The `cli` field is the bare
47
+ * binary name expected on `PATH`; `installHint` is the suggested install
48
+ * command surfaced when the CLI is missing.
49
+ */
50
+ export const BACKBONES = Object.freeze({
51
+ opencode: {
52
+ cli: "opencode",
53
+ installHint: "npm install -g opencode-ai (https://opencode.ai)",
54
+ },
55
+ copilot: {
56
+ cli: "copilot",
57
+ installHint: "npm install -g @github/copilot",
58
+ },
59
+ });
60
+ /**
61
+ * Probe each known backbone CLI and return a presence map. Pure, cheap,
62
+ * synchronous — safe to call before any UI or server-startup decision so
63
+ * hem can degrade gracefully when, e.g., `opencode` is not installed but
64
+ * `copilot` is.
65
+ *
66
+ * @param check - Override for testing (defaults to {@link isCliAvailable}).
67
+ */
68
+ export function getBackboneAvailability(check = isCliAvailable) {
69
+ return {
70
+ opencode: check(BACKBONES.opencode.cli),
71
+ copilot: check(BACKBONES.copilot.cli),
72
+ };
73
+ }
74
+ /**
75
+ * Determine which backbone an opencode-style provider ID belongs to.
76
+ *
77
+ * Provider IDs in {@link PROVIDER_REGISTRY} (currently `github-copilot` and
78
+ * `copilot`) map to the copilot backbone; everything else flows through
79
+ * the opencode server.
80
+ */
81
+ export function backboneForProviderID(providerID) {
82
+ return providerID in PROVIDER_REGISTRY ? "copilot" : "opencode";
83
+ }
@@ -13,9 +13,15 @@
13
13
  * Also exposes low-level `session` and `event` properties for agents that
14
14
  * need direct session control (OrganizationAgent, CrossRefAgent, etc.).
15
15
  */
16
- import type { SseEvent } from "../session.js";
17
16
  import type { ModelSelection, AgentPermissionConfig } from "../types.js";
18
- export type { SseEvent };
17
+ /**
18
+ * Minimal SSE event type for broadcast interception.
19
+ * We only need to match `message.part.updated` and `session.created` events.
20
+ */
21
+ export interface SseEvent {
22
+ type: string;
23
+ properties: Record<string, unknown>;
24
+ }
19
25
  /**
20
26
  * Configuration for initializing an LLM provider.
21
27
  *
@@ -9,6 +9,31 @@
9
9
  * (SIGINT, SIGTERM, SIGHUP, uncaughtException, unhandledRejection),
10
10
  * preventing zombie child processes.
11
11
  */
12
+ /** Injectable lookup function for {@link requireCli}; defaults to `execFileSync`. */
13
+ export type CliLookup = (cmd: string, args: readonly string[]) => unknown;
14
+ /**
15
+ * Probe whether a CLI is reachable on `PATH` without throwing.
16
+ *
17
+ * The opencode and copilot SDKs both `spawn(name)` without first checking
18
+ * that the binary is reachable, so a missing install surfaces as the
19
+ * cryptic `spawn <name> ENOENT`. Callers use this to gate UI behaviour
20
+ * (e.g. mark a provider as "not installed") before invoking the SDK.
21
+ *
22
+ * @param name - Bare CLI name (no path components).
23
+ * @param lookup - Override for testing (defaults to `execFileSync`).
24
+ */
25
+ export declare function isCliAvailable(name: string, lookup?: CliLookup): boolean;
26
+ /**
27
+ * Like {@link isCliAvailable} but throws an actionable error when the CLI
28
+ * is missing. Use at the boundary where a CLI MUST be present (e.g. just
29
+ * before spawning it) to surface a clean message instead of `spawn ENOENT`.
30
+ *
31
+ * @param name - Bare CLI name (no path components).
32
+ * @param hint - Optional install instruction appended to the message.
33
+ * @param lookup - Override for testing (defaults to `execFileSync`).
34
+ * @throws Error with a descriptive message when the CLI is not found.
35
+ */
36
+ export declare function requireCli(name: string, hint?: string, lookup?: CliLookup): void;
12
37
  /**
13
38
  * Find a free TCP port on 127.0.0.1.
14
39
  *
@@ -9,7 +9,49 @@
9
9
  * (SIGINT, SIGTERM, SIGHUP, uncaughtException, unhandledRejection),
10
10
  * preventing zombie child processes.
11
11
  */
12
+ import { execFileSync } from "node:child_process";
12
13
  import { createServer } from "node:net";
14
+ const defaultLookup = (cmd, args) => execFileSync(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
15
+ /** Platform-appropriate `which`/`where` binary name for CLI lookup. */
16
+ function pathFinder() {
17
+ return process.platform === "win32" ? "where" : "which";
18
+ }
19
+ /**
20
+ * Probe whether a CLI is reachable on `PATH` without throwing.
21
+ *
22
+ * The opencode and copilot SDKs both `spawn(name)` without first checking
23
+ * that the binary is reachable, so a missing install surfaces as the
24
+ * cryptic `spawn <name> ENOENT`. Callers use this to gate UI behaviour
25
+ * (e.g. mark a provider as "not installed") before invoking the SDK.
26
+ *
27
+ * @param name - Bare CLI name (no path components).
28
+ * @param lookup - Override for testing (defaults to `execFileSync`).
29
+ */
30
+ export function isCliAvailable(name, lookup = defaultLookup) {
31
+ try {
32
+ lookup(pathFinder(), [name]);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Like {@link isCliAvailable} but throws an actionable error when the CLI
41
+ * is missing. Use at the boundary where a CLI MUST be present (e.g. just
42
+ * before spawning it) to surface a clean message instead of `spawn ENOENT`.
43
+ *
44
+ * @param name - Bare CLI name (no path components).
45
+ * @param hint - Optional install instruction appended to the message.
46
+ * @param lookup - Override for testing (defaults to `execFileSync`).
47
+ * @throws Error with a descriptive message when the CLI is not found.
48
+ */
49
+ export function requireCli(name, hint, lookup = defaultLookup) {
50
+ if (isCliAvailable(name, lookup))
51
+ return;
52
+ const suffix = hint ? `\n${hint}` : "";
53
+ throw new Error(`Required CLI "${name}" was not found on PATH.${suffix}`);
54
+ }
13
55
  // ── Free port discovery ─────────────────────────────────────────────
14
56
  /**
15
57
  * Find a free TCP port on 127.0.0.1.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pruddiman/hem",
3
- "version": "0.0.1-beta-1aff12a",
3
+ "version": "0.0.1-beta-1ec07ab",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hem": "./dist/index.js"
@@ -21,9 +21,10 @@
21
21
  "publish:beta": "node scripts/publish-beta.mjs"
22
22
  },
23
23
  "dependencies": {
24
- "@github/copilot-sdk": "^0.2.2",
24
+ "@github/copilot-sdk": "^0.3.0",
25
25
  "@modelcontextprotocol/sdk": "^1.26.0",
26
- "@opencode-ai/sdk": "^1.14.19",
26
+ "@opencode-ai/sdk": "^1.14.48",
27
+ "@pruddiman/hem": "^0.0.1-beta-1aff12a",
27
28
  "better-sqlite3": "^12.8.0",
28
29
  "commander": "^14.0.3",
29
30
  "fast-glob": "^3.3.3",