@indigoai-us/hq-cli 5.302.0 → 5.303.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.303.0] — 2026-10-01
6
+ - Fixed bundled shell scripts failing on Windows when Git Bash is installed but `bash` is not on `PATH`.
7
+ - The bundled token usage script now reads Claude sessions from `CLAUDE_CONFIG_DIR/projects` and the standard Claude project directories, including nested session folders (subagent transcripts are skipped), when no project directory is given.
8
+
5
9
  ## [5.302.0] — 2026-10-01
6
10
 
7
11
  - Generate company project indexes while preserving hand-written content unless `--accept` is used.
@@ -12,7 +12,8 @@
12
12
 
13
13
  set -euo pipefail
14
14
 
15
- PROJECT_DIR="${CLAUDE_PROJECTS_DIR:-$HOME/.claude/projects/-Users-{your-name}-Documents-HQ}"
15
+ PROJECT_DIR=""
16
+ PROJECT_DIR_SET=0
16
17
  LAST_DAYS=7
17
18
  SINCE=""
18
19
  JSON_MODE=0
@@ -31,6 +32,7 @@ while [ $# -gt 0 ]; do
31
32
  exit 1
32
33
  fi
33
34
  PROJECT_DIR="$2"
35
+ PROJECT_DIR_SET=1
34
36
  shift 2
35
37
  ;;
36
38
  --compare-windows) shift ;; # flag, presence triggers compare mode via --before/--after
@@ -47,14 +49,47 @@ done
47
49
 
48
50
  command -v node >/dev/null 2>&1 || { echo "token-usage-report: node is required" >&2; exit 1; }
49
51
 
52
+ # Resolve roots in the same precedence order as the native command. Explicit
53
+ # project directories and environment overrides select one exact activity root;
54
+ # otherwise include standard, desktop-saved, and discovered Claude roots.
55
+ PROJECT_DIRS=("")
56
+ add_project_dir() {
57
+ local candidate="$1" existing
58
+ [ -d "$candidate" ] || return 0
59
+ for existing in "${PROJECT_DIRS[@]}"; do [ "$existing" = "$candidate" ] && return 0; done
60
+ PROJECT_DIRS+=("$candidate")
61
+ }
62
+ trim_env_path() {
63
+ local value="$1"
64
+ value="${value#"${value%%[![:space:]]*}"}"
65
+ value="${value%"${value##*[![:space:]]}"}"
66
+ printf '%s' "$value"
67
+ }
68
+ if [ "$PROJECT_DIR_SET" -eq 1 ]; then
69
+ if [ ! -d "$PROJECT_DIR" ]; then echo "Project dir not found: $PROJECT_DIR" >&2; exit 1; fi
70
+ add_project_dir "$PROJECT_DIR"
71
+ elif [ -n "$(trim_env_path "${CLAUDE_PROJECTS_DIR:-}")" ]; then
72
+ add_project_dir "$(trim_env_path "${CLAUDE_PROJECTS_DIR:-}")"
73
+ elif [ -n "$(trim_env_path "${CLAUDE_CONFIG_DIR:-}")" ]; then
74
+ add_project_dir "$(trim_env_path "${CLAUDE_CONFIG_DIR:-}")/projects"
75
+ else
76
+ add_project_dir "$HOME/.claude/projects"
77
+ SAVED_PROJECT_DIR="$(node -e 'try { const value = require(process.argv[1]).claudeProjectsDir; if (typeof value === "string" && value.trim()) process.stdout.write(value.trim()); } catch {}' "$HOME/.hq/menubar.json")"
78
+ [ -z "$SAVED_PROJECT_DIR" ] || add_project_dir "$SAVED_PROJECT_DIR"
79
+ for candidate in "$HOME"/.claude-*; do
80
+ [ -d "$candidate/projects" ] && add_project_dir "$candidate/projects"
81
+ done
82
+ fi
83
+ if [ "${#PROJECT_DIRS[@]}" -le 1 ]; then echo "Claude activity folders not found" >&2; exit 1; fi
84
+
50
85
  # --- Compare-windows mode (US-011) ---
51
86
  # Format: --before YYYY-MM-DD:YYYY-MM-DD --after YYYY-MM-DD:YYYY-MM-DD
52
87
  if [ -n "$COMPARE_BEFORE" ] && [ -n "$COMPARE_AFTER" ]; then
53
- node - "$PROJECT_DIR" "$COMPARE_BEFORE" "$COMPARE_AFTER" "$MIN_HOURS" "$JSON_MODE" <<'JS'
88
+ node - "$COMPARE_BEFORE" "$COMPARE_AFTER" "$MIN_HOURS" "$JSON_MODE" "${PROJECT_DIRS[@]:1}" <<'JS'
54
89
  const fs = require("fs");
55
90
  const path = require("path");
56
91
 
57
- const [proj, before, after, minHoursS, jsonModeS] = process.argv.slice(2);
92
+ const [before, after, minHoursS, jsonModeS, ...projectDirs] = process.argv.slice(2);
58
93
  const minHours = parseFloat(minHoursS);
59
94
  const jsonMode = parseInt(jsonModeS, 10) === 1;
60
95
 
@@ -63,13 +98,24 @@ const [bStart, bEnd] = parseRange(before);
63
98
  const [aStart, aEnd] = parseRange(after);
64
99
 
65
100
  const fmt = (n) => n.toLocaleString("en-US");
66
- const listJsonl = () => {
67
- try { return fs.readdirSync(proj).filter((f) => f.endsWith(".jsonl")).sort(); } catch (e) { return []; }
101
+ const listJsonl = (projectDir) => {
102
+ const found = [];
103
+ const visit = (current, relative) => {
104
+ let entries;
105
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { return; }
106
+ for (const entry of entries) {
107
+ const nextRelative = path.join(relative, entry.name);
108
+ if (entry.isDirectory() && entry.name !== "subagents") visit(path.join(current, entry.name), nextRelative);
109
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) found.push(nextRelative);
110
+ }
111
+ };
112
+ visit(projectDir, "");
113
+ return found.sort();
68
114
  };
69
115
 
70
116
  function collect(windowStart, windowEnd) {
71
117
  const rows = [];
72
- for (const name of listJsonl()) {
118
+ for (const proj of projectDirs) for (const name of listJsonl(proj)) {
73
119
  const f = path.join(proj, name);
74
120
  let cr = 0, firstTs = null, lastTs = null;
75
121
  const sid = name.replace(/\.jsonl$/, "");
@@ -130,16 +176,11 @@ JS
130
176
  exit 0
131
177
  fi
132
178
 
133
- if [ ! -d "$PROJECT_DIR" ]; then
134
- echo "Project dir not found: $PROJECT_DIR" >&2
135
- exit 1
136
- fi
137
-
138
- node - "$PROJECT_DIR" "$LAST_DAYS" "$SINCE" "$JSON_MODE" <<'JS'
179
+ node - "$LAST_DAYS" "$SINCE" "$JSON_MODE" "${PROJECT_DIRS[@]:1}" <<'JS'
139
180
  const fs = require("fs");
140
181
  const path = require("path");
141
182
 
142
- const [projectDir, lastDaysS, sinceS, jsonModeS] = process.argv.slice(2);
183
+ const [lastDaysS, sinceS, jsonModeS, ...projectDirs] = process.argv.slice(2);
143
184
  const lastDays = parseInt(lastDaysS, 10);
144
185
  const jsonMode = parseInt(jsonModeS, 10) === 1;
145
186
 
@@ -156,11 +197,20 @@ const fmt = (n) => n.toLocaleString("en-US");
156
197
  const dayTotals = {};
157
198
  const sessionTotals = {};
158
199
 
159
- let names;
160
- try { names = fs.readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).sort(); } catch (e) { names = []; }
161
-
162
- // Parent sessions only (top-level *.jsonl)
163
- for (const name of names) {
200
+ // Parent sessions only; listJsonl skips each session's subagents/ tree.
201
+ for (const projectDir of projectDirs) {
202
+ const listJsonl = (current, relative = "") => {
203
+ let entries;
204
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { return []; }
205
+ return entries.flatMap((entry) => {
206
+ const nextRelative = path.join(relative, entry.name);
207
+ if (entry.isDirectory() && entry.name !== "subagents") return listJsonl(path.join(current, entry.name), nextRelative);
208
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) return [nextRelative];
209
+ return [];
210
+ }).sort();
211
+ };
212
+ const names = listJsonl(projectDir);
213
+ for (const name of names) {
164
214
  const f = path.join(projectDir, name);
165
215
  let mtime;
166
216
  try { mtime = fs.statSync(f).mtime; } catch (e) { continue; }
@@ -170,6 +220,7 @@ for (const name of names) {
170
220
  let firstTs = null, lastTs = null, firstUser = "";
171
221
  let subCount = 0;
172
222
  const sid = name.replace(/\.jsonl$/, "");
223
+ const sessionId = path.basename(path.dirname(projectDir)) + "/" + sid;
173
224
  let content;
174
225
  try { content = fs.readFileSync(f, "utf8"); } catch (e) { continue; }
175
226
  for (const line of content.split(/\r?\n/)) {
@@ -196,15 +247,16 @@ for (const name of names) {
196
247
  if (!day) day = mt;
197
248
  if (day < cutoff) continue;
198
249
  if (!dayTotals[day]) dayTotals[day] = { sessions: new Set(), inp: 0, out: 0, cc: 0, cr: 0 };
199
- dayTotals[day].sessions.add(sid);
250
+ dayTotals[day].sessions.add(sessionId);
200
251
  dayTotals[day].inp += inp;
201
252
  dayTotals[day].out += out;
202
253
  dayTotals[day].cc += cc;
203
254
  dayTotals[day].cr += cr;
204
- sessionTotals[sid] = {
255
+ sessionTotals[sessionId] = {
205
256
  day: day, inp: inp, out: out, cc: cc, cr: cr,
206
257
  eff: Math.trunc(eff), subagents: subCount, first_user: firstUser.split("\n").join(" "),
207
258
  };
259
+ }
208
260
  }
209
261
 
210
262
  const sortedDays = Object.keys(dayTotals).sort();
@@ -35,16 +35,8 @@ import type { BotLogger } from "./log.js";
35
35
  export declare const CLI_UPDATE_RESTART_EXIT_CODE = 75;
36
36
  /** How often the installed package.json is read. */
37
37
  export declare const CLI_UPDATE_CHECK_INTERVAL_MS = 60000;
38
- /** Consecutive checks that must agree on the new version before acting. */
39
- export declare const CLI_UPDATE_CONFIRMATIONS = 2;
40
- /** At most this many update restarts per window, whatever the versions. */
41
- export declare const CLI_UPDATE_RESTART_LIMIT = 4;
42
- export declare const CLI_UPDATE_RESTART_WINDOW_MS: number;
43
- /** A restart that came back on the old version blocks that target this long. */
44
- export declare const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS: number;
45
38
  /** Set to "1" to turn the watcher off (development runs). */
46
39
  export declare const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
47
- export declare const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
48
40
  export type InstalledCliRead = {
49
41
  ok: true;
50
42
  version: string;
@@ -42,15 +42,15 @@ export const CLI_UPDATE_RESTART_EXIT_CODE = 75;
42
42
  /** How often the installed package.json is read. */
43
43
  export const CLI_UPDATE_CHECK_INTERVAL_MS = 60_000;
44
44
  /** Consecutive checks that must agree on the new version before acting. */
45
- export const CLI_UPDATE_CONFIRMATIONS = 2;
45
+ const CLI_UPDATE_CONFIRMATIONS = 2;
46
46
  /** At most this many update restarts per window, whatever the versions. */
47
- export const CLI_UPDATE_RESTART_LIMIT = 4;
48
- export const CLI_UPDATE_RESTART_WINDOW_MS = 60 * 60_000;
47
+ const CLI_UPDATE_RESTART_LIMIT = 4;
48
+ const CLI_UPDATE_RESTART_WINDOW_MS = 60 * 60_000;
49
49
  /** A restart that came back on the old version blocks that target this long. */
50
- export const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS = 6 * 60 * 60_000;
50
+ const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS = 6 * 60 * 60_000;
51
51
  /** Set to "1" to turn the watcher off (development runs). */
52
52
  export const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
53
- export const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
53
+ const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
54
54
  /**
55
55
  * The hq-cli version installed behind `entry`, resolved the way the next launch
56
56
  * of the same command line will resolve it: follow the symlinks now (a pnpm or
@@ -18,9 +18,7 @@
18
18
  * - A fire is recorded before it is handed over, so a crash never runs the
19
19
  * same due time twice.
20
20
  */
21
- import { type BotApi, type BotJobRecord, type InboxItem, type JobRunCompletion } from "./api.js";
22
- export declare const LOCAL_JOBS_FILE = "jobs.json";
23
- export declare const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
21
+ import { type BotApi, type InboxItem } from "./api.js";
24
22
  export interface LocalJob {
25
23
  jobId: string;
26
24
  prompt: string;
@@ -40,9 +38,7 @@ export interface LocalJob {
40
38
  lastStatus?: string;
41
39
  runCount?: number;
42
40
  }
43
- export declare function localJobsPath(dir: string): string;
44
41
  export declare function readLocalJobs(dir: string): LocalJob[];
45
- export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
46
42
  /**
47
43
  * Run a read-change-write of `jobs.json` under an exclusive lock. The running
48
44
  * bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
@@ -67,40 +63,24 @@ export interface CronMatcher {
67
63
  export declare function toStandardCron(expr: string): string | null;
68
64
  /** Parse a standard 5-field cron, or null when it is not one. */
69
65
  export declare function parseCron(expr: string): CronMatcher | null;
70
- export declare function cronMatches(cron: CronMatcher, date: Date, timeZone: string): boolean;
71
- /**
72
- * The newest time this job was due, at or before `now` and after the last one
73
- * already fired. Null when nothing new is due.
74
- */
75
- /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
76
- export declare function rateIntervalMs(expr: string): number | null;
77
- export declare function isRunnableSchedule(expr: string): boolean;
78
66
  export declare function latestDueFire(job: LocalJob, now: Date): Date | null;
79
67
  /** The next time this job is due after `now`, within a week; null otherwise. */
80
68
  export declare function nextDueFire(job: LocalJob, now: Date): Date | null;
81
- export declare function toJobRecord(job: LocalJob, now?: Date): BotJobRecord;
82
69
  export declare function addLocalJob(dir: string, input: {
83
70
  prompt: string;
84
71
  cron: string;
85
72
  timezone: string;
86
73
  context?: Record<string, unknown>;
87
74
  }, now?: Date): LocalJob;
88
- /** Remove a job. Returns false when the bot has no job with that id. */
89
- export declare function removeLocalJob(dir: string, jobId: string): boolean;
90
75
  /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
91
76
  export declare function resetLocalJobChecks(): void;
92
77
  export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
93
- export declare function isLocalJobMessage(messageId: string): boolean;
94
- /** Record how a local run finished and release the job for its next fire. */
95
- export declare function finishLocalJobRun(dir: string, jobId: string, messageId: string, completion: Pick<JobRunCompletion, "status">, now?: Date): void;
96
78
  /**
97
79
  * The bot's cloud client, with its scheduled runs answered on this computer:
98
80
  * due local jobs join the inbox, and their ack and completion are recorded
99
81
  * locally instead of being sent to the cloud.
100
82
  */
101
83
  export declare function withLocalJobs(api: BotApi, dir: string, clock?: () => Date): BotApi;
102
- /** The most jobs one bot may hold (the same cap the cloud uses). */
103
- export declare const MAX_LOCAL_JOBS = 25;
104
84
  /**
105
85
  * `hq bot jobs` against the bot's own folder: the same three calls the cloud
106
86
  * client offers, answered from `jobs.json`.
@@ -22,15 +22,15 @@ import * as fs from "node:fs";
22
22
  import * as path from "node:path";
23
23
  import { randomUUID } from "node:crypto";
24
24
  import { BotApiError } from "./api.js";
25
- export const LOCAL_JOBS_FILE = "jobs.json";
26
- export const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
25
+ const LOCAL_JOBS_FILE = "jobs.json";
26
+ const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
27
27
  /** How far back a missed fire is still looked for (one week). */
28
28
  const LOOKBACK_MINUTES = 7 * 24 * 60;
29
29
  /** A run that never reported back releases its job after this long. */
30
30
  const IN_FLIGHT_RELEASE_MS = 60 * 60 * 1000;
31
31
  /** A fire is "late" (a catch-up) once this long past its due time. */
32
32
  const STALE_AFTER_MS = 10 * 60 * 1000;
33
- export function localJobsPath(dir) {
33
+ function localJobsPath(dir) {
34
34
  return path.join(dir, LOCAL_JOBS_FILE);
35
35
  }
36
36
  export function readLocalJobs(dir) {
@@ -42,7 +42,7 @@ export function readLocalJobs(dir) {
42
42
  return [];
43
43
  }
44
44
  }
45
- export function writeLocalJobs(dir, jobs) {
45
+ function writeLocalJobs(dir, jobs) {
46
46
  const file = localJobsPath(dir);
47
47
  const tmp = `${file}.${process.pid}.tmp`;
48
48
  fs.writeFileSync(tmp, JSON.stringify({ v: 1, jobs }, null, 2));
@@ -210,7 +210,7 @@ function zonedParts(date, timeZone) {
210
210
  weekday: WEEKDAY[get("weekday")] ?? -1,
211
211
  };
212
212
  }
213
- export function cronMatches(cron, date, timeZone) {
213
+ function cronMatches(cron, date, timeZone) {
214
214
  const t = zonedParts(date, timeZone);
215
215
  if (!cron.minutes.has(t.minute) || !cron.hours.has(t.hour) || !cron.months.has(t.month))
216
216
  return false;
@@ -226,7 +226,7 @@ export function cronMatches(cron, date, timeZone) {
226
226
  * already fired. Null when nothing new is due.
227
227
  */
228
228
  /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
229
- export function rateIntervalMs(expr) {
229
+ function rateIntervalMs(expr) {
230
230
  const match = /^rate\((\d+)\s+(minute|minutes|hour|hours|day|days)\)$/.exec(expr.trim());
231
231
  if (!match)
232
232
  return null;
@@ -234,7 +234,7 @@ export function rateIntervalMs(expr) {
234
234
  const unit = match[2].startsWith("minute") ? 60_000 : match[2].startsWith("hour") ? 3_600_000 : 86_400_000;
235
235
  return n > 0 ? n * unit : null;
236
236
  }
237
- export function isRunnableSchedule(expr) {
237
+ function isRunnableSchedule(expr) {
238
238
  return rateIntervalMs(expr) !== null || parseCron(expr) !== null;
239
239
  }
240
240
  export function latestDueFire(job, now) {
@@ -281,7 +281,7 @@ export function nextDueFire(job, now) {
281
281
  return null;
282
282
  }
283
283
  // ─── The job list (what `hq bot jobs` reads and writes) ──────────────────────
284
- export function toJobRecord(job, now = new Date()) {
284
+ function toJobRecord(job, now = new Date()) {
285
285
  const next = nextDueFire(job, now);
286
286
  return {
287
287
  jobId: job.jobId,
@@ -311,7 +311,7 @@ export function addLocalJob(dir, input, now = new Date()) {
311
311
  return job;
312
312
  }
313
313
  /** Remove a job. Returns false when the bot has no job with that id. */
314
- export function removeLocalJob(dir, jobId) {
314
+ function removeLocalJob(dir, jobId) {
315
315
  return withJobsLock(dir, () => {
316
316
  const jobs = readLocalJobs(dir);
317
317
  const next = jobs.filter((job) => job.jobId !== jobId);
@@ -375,11 +375,11 @@ export function takeDueLocalJobRuns(dir, now = new Date()) {
375
375
  return items;
376
376
  });
377
377
  }
378
- export function isLocalJobMessage(messageId) {
378
+ function isLocalJobMessage(messageId) {
379
379
  return messageId.startsWith(LOCAL_JOB_MESSAGE_PREFIX);
380
380
  }
381
381
  /** Record how a local run finished and release the job for its next fire. */
382
- export function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
382
+ function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
383
383
  withJobsLock(dir, () => {
384
384
  const jobs = readLocalJobs(dir);
385
385
  let changed = false;
@@ -428,7 +428,7 @@ export function withLocalJobs(api, dir, clock = () => new Date()) {
428
428
  return local;
429
429
  }
430
430
  /** The most jobs one bot may hold (the same cap the cloud uses). */
431
- export const MAX_LOCAL_JOBS = 25;
431
+ const MAX_LOCAL_JOBS = 25;
432
432
  /**
433
433
  * `hq bot jobs` against the bot's own folder: the same three calls the cloud
434
434
  * client offers, answered from `jobs.json`.
@@ -23,7 +23,6 @@ export interface DaemonConfig {
23
23
  }
24
24
  export type SyncOwner = "daemon" | "desktop-app";
25
25
  export declare function effectiveSyncOwner(config: Pick<DaemonConfig, "syncOwner">, machineType: MachineType): SyncOwner;
26
- export declare function emptyDaemonConfig(): DaemonConfig;
27
26
  export declare function readDaemonConfig(file: string): DaemonConfig;
28
27
  export declare function writeDaemonConfig(file: string, config: DaemonConfig): void;
29
28
  /**
@@ -13,7 +13,7 @@ import { ensurePrivateDir } from "./paths.js";
13
13
  export function effectiveSyncOwner(config, machineType) {
14
14
  return config.syncOwner ?? (machineType === "desktop" ? "desktop-app" : "daemon");
15
15
  }
16
- export function emptyDaemonConfig() {
16
+ function emptyDaemonConfig() {
17
17
  return { version: 1, syncPaused: false, enable: [], disable: [] };
18
18
  }
19
19
  function stringList(value) {
@@ -1,7 +1,5 @@
1
1
  /** File name of the snapshot inside the published report. */
2
2
  export declare const REPORT_PREVIEW_FILE = "hq-preview.png";
3
- export declare const REPORT_PREVIEW_WIDTH = 1280;
4
- export declare const REPORT_PREVIEW_HEIGHT = 720;
5
3
  /** First installed browser that can take a snapshot, or null. */
6
4
  export declare function findSnapshotBrowser(env?: NodeJS.ProcessEnv, platform?: string, exists?: (file: string) => boolean): string | null;
7
5
  export declare function snapshotArgs(indexFile: string, outFile: string, profileDir: string): string[];
@@ -13,8 +13,8 @@ import * as path from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  /** File name of the snapshot inside the published report. */
15
15
  export const REPORT_PREVIEW_FILE = "hq-preview.png";
16
- export const REPORT_PREVIEW_WIDTH = 1280;
17
- export const REPORT_PREVIEW_HEIGHT = 720;
16
+ const REPORT_PREVIEW_WIDTH = 1280;
17
+ const REPORT_PREVIEW_HEIGHT = 720;
18
18
  const SNAPSHOT_TIMEOUT_MS = 30_000;
19
19
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
20
20
  const BROWSER_PATHS = {
@@ -29,6 +29,7 @@
29
29
  * script needs to see termination, not a success code.
30
30
  */
31
31
  import { spawnSync } from "child_process";
32
+ import * as fs from "node:fs";
32
33
  import * as path from "path";
33
34
  import { resolveBundledAsset } from "./hq-roots.js";
34
35
  /**
@@ -41,6 +42,93 @@ export function interpreterFor(asset) {
41
42
  const ext = path.extname(asset).toLowerCase();
42
43
  return ext === ".mjs" || ext === ".js" || ext === ".cjs" ? "node" : "bash";
43
44
  }
45
+ function envValue(env, name) {
46
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
47
+ return key ? env[key] : undefined;
48
+ }
49
+ function isFile(file, lookupErrors) {
50
+ try {
51
+ return fs.statSync(file).isFile();
52
+ }
53
+ catch (error) {
54
+ // Missing PATH candidates are expected during lookup; retain the cause so
55
+ // the final actionable error can expose why no interpreter was resolved.
56
+ lookupErrors.push(error);
57
+ return false;
58
+ }
59
+ }
60
+ function isWithinWindowsDirectory(file, directory) {
61
+ const normalizedFile = path.win32.resolve(file).toLowerCase();
62
+ const normalizedDirectory = path.win32.resolve(directory).toLowerCase();
63
+ return (normalizedFile === normalizedDirectory ||
64
+ normalizedFile.startsWith(`${normalizedDirectory}${path.win32.sep}`));
65
+ }
66
+ function isExcludedWindowsBash(file, env) {
67
+ if (path.win32.basename(file).toLowerCase() !== "bash.exe")
68
+ return false;
69
+ const windowsDir = envValue(env, "SystemRoot") ?? envValue(env, "WINDIR") ?? "C:\\Windows";
70
+ const systemBash = path.win32.resolve(windowsDir, "System32", "bash.exe");
71
+ if (path.win32.resolve(file).toLowerCase() === systemBash.toLowerCase())
72
+ return true;
73
+ const localAppData = envValue(env, "LOCALAPPDATA");
74
+ return Boolean(localAppData &&
75
+ isWithinWindowsDirectory(file, path.win32.resolve(localAppData, "Microsoft", "WindowsApps")));
76
+ }
77
+ function findOnWindowsPath(executable, env, lookupErrors) {
78
+ const pathValue = envValue(env, "PATH") ?? "";
79
+ const extensions = executable.includes(".")
80
+ ? [""]
81
+ : (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
82
+ .split(";")
83
+ .filter(Boolean)
84
+ .map((ext) => ext.toLowerCase());
85
+ for (const entry of pathValue.split(";").filter(Boolean)) {
86
+ const directory = entry.trim().replace(/^"|"$/g, "");
87
+ for (const extension of extensions) {
88
+ const candidate = path.win32.join(directory, `${executable}${extension}`);
89
+ if (!isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors))
90
+ return candidate;
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+ function resolveWindowsBash(env) {
96
+ const lookupErrors = [];
97
+ const configured = envValue(env, "CLAUDE_CODE_GIT_BASH_PATH");
98
+ if (configured && !isExcludedWindowsBash(configured, env) && isFile(configured, lookupErrors)) {
99
+ return { executable: configured, cause: lookupErrors.at(-1) };
100
+ }
101
+ const pathBash = findOnWindowsPath("bash", env, lookupErrors);
102
+ if (pathBash)
103
+ return { executable: pathBash, cause: lookupErrors.at(-1) };
104
+ const gitExe = findOnWindowsPath("git.exe", env, lookupErrors);
105
+ if (gitExe) {
106
+ const derived = path.win32.resolve(path.win32.dirname(gitExe), "..", "bin", "bash.exe");
107
+ if (!isExcludedWindowsBash(derived, env) && isFile(derived, lookupErrors)) {
108
+ return { executable: derived, cause: lookupErrors.at(-1) };
109
+ }
110
+ }
111
+ const programFiles = envValue(env, "ProgramFiles") ?? "C:\\Program Files";
112
+ const localAppData = envValue(env, "LOCALAPPDATA");
113
+ const standardPaths = [
114
+ path.win32.join(programFiles, "Git", "bin", "bash.exe"),
115
+ ...(localAppData ? [path.win32.join(localAppData, "Programs", "Git", "bin", "bash.exe")] : []),
116
+ ];
117
+ const executable = standardPaths.find((candidate) => !isExcludedWindowsBash(candidate, env) && isFile(candidate, lookupErrors));
118
+ return {
119
+ executable,
120
+ cause: lookupErrors.at(-1) ??
121
+ Object.assign(new Error("Bash was not found (ENOENT)"), { code: "ENOENT" }),
122
+ };
123
+ }
124
+ function bashNotFoundError(asset, cause) {
125
+ const isHandoff = asset.replaceAll("\\", "/").endsWith("core/scripts/handoff-finalize.sh");
126
+ const recovery = isHandoff
127
+ ? "The handoff was not saved; rerun the command."
128
+ : "The script did not run; rerun this command.";
129
+ return new Error(`Bash was not found to run bundled script ${asset}. Install Git for Windows or ` +
130
+ `set CLAUDE_CODE_GIT_BASH_PATH. ${recovery}`, { cause });
131
+ }
44
132
  /**
45
133
  * Execute a bundled scaffold script and return its exit code.
46
134
  *
@@ -56,12 +144,26 @@ export function runBundledScript(opts) {
56
144
  env.CLAUDE_PROJECT_DIR = opts.hqRoot;
57
145
  }
58
146
  Object.assign(env, opts.env ?? {});
59
- const result = spawnSync(interpreterFor(opts.asset), [script, ...(opts.args ?? [])], {
147
+ const interpreter = interpreterFor(opts.asset);
148
+ const windowsBash = process.platform === "win32" && interpreter === "bash"
149
+ ? resolveWindowsBash(env)
150
+ : undefined;
151
+ const executable = windowsBash?.executable ?? interpreter;
152
+ if (windowsBash && !windowsBash.executable) {
153
+ throw bashNotFoundError(opts.asset, windowsBash.cause);
154
+ }
155
+ const result = spawnSync(executable, [script, ...(opts.args ?? [])], {
60
156
  cwd: opts.cwd,
61
157
  env,
62
158
  stdio: "inherit",
63
159
  });
64
160
  if (result.error) {
161
+ const spawnError = result.error;
162
+ if (process.platform === "win32" &&
163
+ interpreter === "bash" &&
164
+ spawnError.code === "ENOENT") {
165
+ throw bashNotFoundError(opts.asset, spawnError);
166
+ }
65
167
  throw new Error(`Failed to run bundled script ${opts.asset}: ${result.error.message}`);
66
168
  }
67
169
  if (result.signal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.302.0",
3
+ "version": "5.303.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {