@indigoai-us/hq-cli 5.345.148 → 5.345.150

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 (83) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +2 -0
  3. package/dist/command-catalog.generated.d.ts +62 -14
  4. package/dist/command-catalog.generated.js +74 -14
  5. package/dist/command-help.js +2 -1
  6. package/dist/commands/_patterns.d.ts +6 -0
  7. package/dist/commands/_patterns.js +6 -0
  8. package/dist/commands/access.d.ts +45 -2
  9. package/dist/commands/access.js +537 -223
  10. package/dist/commands/billing-usage.d.ts +33 -2
  11. package/dist/commands/billing-usage.js +75 -4
  12. package/dist/commands/bot.js +2 -2
  13. package/dist/commands/channels.d.ts +27 -0
  14. package/dist/commands/channels.js +108 -4
  15. package/dist/commands/core-refresh-vault-access-native.d.ts +16 -0
  16. package/dist/commands/core-refresh-vault-access-native.js +142 -9
  17. package/dist/commands/core.js +4 -1
  18. package/dist/commands/dm.js +9 -1
  19. package/dist/commands/groups.js +9 -6
  20. package/dist/commands/install-global.d.ts +1 -8
  21. package/dist/commands/install-global.js +11 -27
  22. package/dist/commands/lanes-questions.js +7 -4
  23. package/dist/commands/lanes.js +13 -6
  24. package/dist/commands/library-add.d.ts +31 -1
  25. package/dist/commands/library-add.js +69 -2
  26. package/dist/commands/library.js +37 -3
  27. package/dist/commands/members.d.ts +35 -0
  28. package/dist/commands/members.js +65 -14
  29. package/dist/commands/reindex.js +19 -0
  30. package/dist/commands/secrets.d.ts +5 -0
  31. package/dist/commands/secrets.js +11 -6
  32. package/dist/hook-engine-path.js +10 -3
  33. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +63282 -62480
  34. package/dist/hq-cloud-sync-runner/package.json +1 -1
  35. package/dist/index.d.ts +10 -0
  36. package/dist/index.js +70 -5
  37. package/dist/lazy-commands.js +2 -0
  38. package/dist/lib/billing/usage-cost.d.ts +31 -1
  39. package/dist/lib/billing/usage-cost.js +46 -0
  40. package/dist/lib/db/local.d.ts +2 -2
  41. package/dist/lib/db/local.js +2 -2
  42. package/dist/lib/db/sql.js +2 -2
  43. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  44. package/dist/lib/db/sqlite-binding.js +168 -0
  45. package/dist/lib/doctor/checks/anywhere.d.ts +12 -0
  46. package/dist/lib/doctor/checks/anywhere.js +63 -12
  47. package/dist/lib/doctor/checks/cli-path.d.ts +2 -1
  48. package/dist/lib/doctor/checks/cli-path.js +2 -1
  49. package/dist/lib/doctor/checks/hook-engine.js +2 -1
  50. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  51. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  52. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  53. package/dist/lib/hook-engine/reconcile.js +134 -0
  54. package/dist/lib/hook-engine/registration.d.ts +17 -0
  55. package/dist/lib/hook-engine/registration.js +50 -0
  56. package/dist/lib/install/claude-targets.d.ts +2 -1
  57. package/dist/lib/install/claude-targets.js +8 -3
  58. package/dist/lib/install/codex-targets.d.ts +3 -0
  59. package/dist/lib/install/codex-targets.js +15 -4
  60. package/dist/lib/install/hook-target.d.ts +9 -0
  61. package/dist/lib/install/hook-target.js +39 -0
  62. package/dist/lib/lanes/index.d.ts +1 -1
  63. package/dist/lib/lanes/index.js +1 -1
  64. package/dist/lib/lanes/store.d.ts +11 -0
  65. package/dist/lib/lanes/store.js +20 -0
  66. package/dist/lib/lanes/watch.js +5 -3
  67. package/dist/lib/windows/console-encoding.d.ts +24 -0
  68. package/dist/lib/windows/console-encoding.js +83 -0
  69. package/dist/lib/windows/install-followup.d.ts +35 -0
  70. package/dist/lib/windows/install-followup.js +142 -0
  71. package/dist/lib/windows/user-path.d.ts +80 -3
  72. package/dist/lib/windows/user-path.js +187 -26
  73. package/dist/main.js +2 -1
  74. package/dist/unknown-command.d.ts +18 -0
  75. package/dist/unknown-command.js +88 -0
  76. package/dist/utils/cognito-session.d.ts +8 -0
  77. package/dist/utils/cognito-session.js +14 -2
  78. package/dist/utils/hook-trust.d.ts +9 -0
  79. package/dist/utils/hook-trust.js +30 -2
  80. package/dist/utils/qmd-native-binding-error.js +3 -1
  81. package/dist/utils/resolve-executable.d.ts +2 -0
  82. package/dist/utils/resolve-executable.js +6 -2
  83. package/package.json +9 -9
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Work that npm 11 will not run from postinstall: put the hq shim folder on
3
+ * the user PATH, and point the mesh daemon at this install once per CLI version.
4
+ * Failures are ignored. Hook, version, and staged-install routes do not run it.
5
+ *
6
+ * Both steps record their result in ~/.hq/install-followup.json, so later
7
+ * commands start no PowerShell or daemon process for the same version.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { ensureShimOnUserPath, npmShimDir } from "./user-path.js";
15
+ const MARKER_NAME = "install-followup.json";
16
+ export function isInstallFollowupSkipped(argv, env) {
17
+ if (env.HQ_INSTALL_FOLLOWUP === "1" || env.HQ_STAGED_INSTALL === "1")
18
+ return true;
19
+ const args = argv.slice(2).filter((arg) => arg !== "--verbose");
20
+ if (args[0] === "mesh" && args[1] === "daemon")
21
+ return true;
22
+ if (args[0] === "daemon" && (args[1] === "run" || args[1] === "install"))
23
+ return true;
24
+ return false;
25
+ }
26
+ export function isInstalledPackageEntry(entry) {
27
+ return Boolean(entry && /node_modules[/\\]@indigoai-us[/\\]hq-cli[/\\]/i.test(entry));
28
+ }
29
+ function packageVersion() {
30
+ try {
31
+ const pkgPath = fileURLToPath(new URL("../../../package.json", import.meta.url));
32
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
33
+ return typeof pkg.version === "string" ? pkg.version : "";
34
+ }
35
+ catch {
36
+ return "";
37
+ }
38
+ }
39
+ /** A failed daemon install blocks for up to 30 s, so it is retried at most hourly. */
40
+ export const MESH_RETRY_MS = 60 * 60 * 1000;
41
+ function markerFile(env) {
42
+ const home = env.USERPROFILE || env.HOME || os.homedir();
43
+ return path.join(home, ".hq", MARKER_NAME);
44
+ }
45
+ function readMarker(env) {
46
+ try {
47
+ const parsed = JSON.parse(fs.readFileSync(markerFile(env), "utf8"));
48
+ const marker = {};
49
+ if (typeof parsed.version === "string")
50
+ marker.version = parsed.version;
51
+ if (typeof parsed.path?.dir === "string" && typeof parsed.path.version === "string") {
52
+ marker.path = { dir: parsed.path.dir, version: parsed.path.version };
53
+ }
54
+ if (typeof parsed.meshFailedVersion === "string" && typeof parsed.meshFailedAt === "number") {
55
+ marker.meshFailedVersion = parsed.meshFailedVersion;
56
+ marker.meshFailedAt = parsed.meshFailedAt;
57
+ }
58
+ return marker;
59
+ }
60
+ catch {
61
+ return {};
62
+ }
63
+ }
64
+ function writeMarker(env, marker) {
65
+ const file = markerFile(env);
66
+ fs.mkdirSync(path.dirname(file), { recursive: true });
67
+ fs.writeFileSync(file, `${JSON.stringify(marker)}\n`);
68
+ }
69
+ /** True only when `hq mesh daemon install --if-stale` exited 0. */
70
+ function defaultSpawnMesh(argv, env) {
71
+ const entry = argv[1];
72
+ if (!entry)
73
+ return false;
74
+ const result = spawnSync(process.execPath, [entry, "mesh", "daemon", "install", "--if-stale"], {
75
+ env: { ...env, HQ_INSTALL_FOLLOWUP: "1" },
76
+ timeout: 30_000,
77
+ windowsHide: true,
78
+ stdio: "ignore",
79
+ });
80
+ return result.status === 0 && !result.error;
81
+ }
82
+ export function runWindowsInstallFollowup(deps) {
83
+ const env = deps.env ?? process.env;
84
+ if (isInstallFollowupSkipped(deps.argv, env))
85
+ return;
86
+ const version = deps.version ?? packageVersion();
87
+ if (!version)
88
+ return;
89
+ const read = deps.readMarker ?? (() => readMarker(env));
90
+ const write = deps.writeMarker ?? ((marker) => writeMarker(env, marker));
91
+ const save = (update) => {
92
+ try {
93
+ write({ ...read(), ...update });
94
+ }
95
+ catch {
96
+ // A marker failure only means the next command tries again.
97
+ }
98
+ };
99
+ let shimDir;
100
+ try {
101
+ shimDir = (deps.shimDir ?? (() => npmShimDir(deps.argv[1], fs.existsSync)))();
102
+ }
103
+ catch {
104
+ shimDir = null;
105
+ }
106
+ const marker = read();
107
+ if (shimDir && !(marker.path?.dir === shimDir && marker.path.version === version)) {
108
+ try {
109
+ (deps.ensurePath ?? (() => {
110
+ ensureShimOnUserPath({ entry: deps.argv[1], env });
111
+ }))();
112
+ }
113
+ catch {
114
+ // PATH repair must not block the command the user asked for.
115
+ }
116
+ // Recorded whatever the outcome: a refused or locked HKCU is retried after
117
+ // the next upgrade, and `hq doctor` keeps reporting the missing folder.
118
+ save({ path: { dir: shimDir, version } });
119
+ }
120
+ if (!isInstalledPackageEntry(deps.argv[1]))
121
+ return;
122
+ const mesh = read();
123
+ if (mesh.version === version)
124
+ return;
125
+ const now = (deps.now ?? Date.now)();
126
+ if (mesh.meshFailedVersion === version && mesh.meshFailedAt !== undefined && now - mesh.meshFailedAt < MESH_RETRY_MS)
127
+ return;
128
+ let ok = false;
129
+ try {
130
+ ok = (deps.spawnMesh ?? (() => defaultSpawnMesh(deps.argv, env)))();
131
+ }
132
+ catch {
133
+ // The daemon install is best-effort, same as the old postinstall `|| true`.
134
+ }
135
+ // Only a successful install marks the version done. A failure is retried
136
+ // after MESH_RETRY_MS.
137
+ if (ok)
138
+ save({ version, meshFailedVersion: undefined, meshFailedAt: undefined });
139
+ else
140
+ save({ meshFailedVersion: version, meshFailedAt: now });
141
+ }
142
+ //# sourceMappingURL=install-followup.js.map
@@ -1,4 +1,4 @@
1
- export type RegRun = (cmd: string, args: string[]) => {
1
+ export type PowerShellRun = (script: string, env: NodeJS.ProcessEnv) => {
2
2
  status: number | null;
3
3
  stdout: string;
4
4
  };
@@ -14,19 +14,38 @@ export interface ShimPathDeps {
14
14
  /** The running CLI entry script (process.argv[1]). */
15
15
  entry?: string;
16
16
  exists?: (p: string) => boolean;
17
- run?: RegRun;
17
+ powerShell?: PowerShellRun;
18
18
  env?: NodeJS.ProcessEnv;
19
19
  }
20
+ /** The longest user Path HQ will write. Longer values break `setx` and some installers. */
21
+ export declare const MAX_USER_PATH_LENGTH = 2047;
22
+ /**
23
+ * Tests on Windows point the scripts at a scratch key with this variable. The
24
+ * scripts accept only keys under HKCU:\Software\HQTest\ and skip the
25
+ * broadcast for them.
26
+ */
27
+ export declare const USER_PATH_TEST_KEY_ENV = "HQ_USER_PATH_TEST_KEY";
20
28
  /** The npm prefix folder above the package's `node_modules`, when `hq.cmd` is there. */
21
29
  export declare function npmShimDir(entry: string | undefined, exists: (p: string) => boolean): string | null;
22
30
  /** Expand `%NAME%` references the way Windows does, matching names case-insensitively. */
23
31
  export declare function expandWindowsEnv(value: string, env: NodeJS.ProcessEnv): string;
32
+ /**
33
+ * Prints `U:<base64>` for the unexpanded user Path and `M:<base64>` for the
34
+ * machine Path, base64 of the UTF-16LE bytes, or `U-` / `M-` when a value
35
+ * cannot be read. Base64 is ASCII, so the console code page cannot change it.
36
+ */
37
+ export declare const READ_PATHS_SCRIPT: string;
38
+ /** Decode READ_PATHS_SCRIPT output. Null for a value that could not be read. */
39
+ export declare function parseReadPathsOutput(stdout: string): {
40
+ user: string | null;
41
+ machine: string | null;
42
+ };
24
43
  /**
25
44
  * The PATH a new terminal will get: the user Path then the machine Path, as
26
45
  * stored in the registry. Falls back to this process's PATH when neither can
27
46
  * be read.
28
47
  */
29
- export declare function readPersistedPath(run: RegRun, env: NodeJS.ProcessEnv): {
48
+ export declare function readPersistedPath(run: PowerShellRun, env: NodeJS.ProcessEnv): {
30
49
  value: string;
31
50
  source: "registry" | "process";
32
51
  };
@@ -45,4 +64,62 @@ export declare const PATH_REFRESH_VAR = "HQ_PATH_REFRESH";
45
64
  export declare function pathFixCommand(dir: string): string;
46
65
  /** Lines to print when the shim folder is missing from PATH; empty when it is present. */
47
66
  export declare function shimPathWarning(check: ShimPathCheck): string[];
67
+ export type UserPathWriteReason = "unreadable" | "present" | "written" | "write-failed" | "unsafe-value" | "too-long";
68
+ export interface UserPathWriteResult {
69
+ written: boolean;
70
+ reason: UserPathWriteReason;
71
+ }
72
+ /** Adds a folder to HKCU Path in one step: read, check, and write happen together. */
73
+ export interface UserPathStore {
74
+ appendUserPath(dir: string): UserPathWriteResult;
75
+ }
76
+ export type UserPathAppendPlan = {
77
+ action: "write";
78
+ value: string;
79
+ } | {
80
+ action: "skip";
81
+ reason: "present" | "unsafe-value" | "too-long";
82
+ };
83
+ /**
84
+ * Decide the new unexpanded user Path. Membership is decided on the expanded
85
+ * value so `%APPDATA%\npm` counts. The stored text keeps `%VARS%`. A value
86
+ * that already holds U+FFFD was garbled by an earlier decode and is never
87
+ * written back. The ensure script applies the same rules inside PowerShell.
88
+ */
89
+ export declare function planUserPathAppend(current: string, dir: string, env: NodeJS.ProcessEnv): UserPathAppendPlan;
90
+ /** Exit codes of ENSURE_USER_PATH_SCRIPT. */
91
+ export declare const ENSURE_EXIT: {
92
+ readonly written: 0;
93
+ readonly badInput: 2;
94
+ readonly unreadable: 3;
95
+ readonly unsafeValue: 4;
96
+ readonly tooLong: 5;
97
+ readonly writtenNoBroadcast: 6;
98
+ readonly present: 10;
99
+ };
100
+ /**
101
+ * Appends $env:HQ_SHIM_DIR to the HKCU Path as REG_EXPAND_SZ, then broadcasts
102
+ * the change. The value is read, checked, and written inside this script, so
103
+ * no console encoding touches it. Mirrors planUserPathAppend.
104
+ */
105
+ export declare const ENSURE_USER_PATH_SCRIPT: string;
106
+ export declare function ensureExitReason(status: number | null): UserPathWriteResult;
107
+ export declare function powershellUserPathStore(run?: PowerShellRun, extraEnv?: NodeJS.ProcessEnv): UserPathStore;
108
+ export interface EnsureShimResult {
109
+ shimDir: string;
110
+ written: boolean;
111
+ onPath: boolean;
112
+ source: ShimPathCheck["source"];
113
+ reason: UserPathWriteReason;
114
+ }
115
+ export interface EnsureShimDeps extends ShimPathDeps {
116
+ store?: UserPathStore;
117
+ }
118
+ export declare function resetUserPathEnsureCacheForTests(): void;
119
+ /**
120
+ * Add the npm shim folder to HKCU Path when it is missing. Null off Windows
121
+ * or when this process is not an npm global install. A failed read does not
122
+ * write. Machine Path is never written.
123
+ */
124
+ export declare function ensureShimOnUserPath(deps?: EnsureShimDeps): EnsureShimResult | null;
48
125
  //# sourceMappingURL=user-path.d.ts.map
@@ -3,14 +3,28 @@
3
3
  *
4
4
  * npm writes `hq.cmd` directly under its prefix (never `<prefix>\bin`). When
5
5
  * that folder is not on the user or machine Path, a new terminal cannot find
6
- * `hq` even though the install succeeded. HQ detects this and prints the exact
7
- * fix; it never edits PATH itself.
6
+ * `hq` even though the install succeeded. The supported install adds that
7
+ * folder to the user PATH (HKCU only) and broadcasts the change. It never
8
+ * edits the machine PATH. When the add fails, doctor prints the exact command.
9
+ *
10
+ * Windows PowerShell 5.1 and reg.exe print piped output in the console code
11
+ * page, so a Path holding `C:\Users\José` comes back garbled when read as
12
+ * UTF-8. Reads therefore return the value as base64 of its UTF-16LE bytes, and
13
+ * the append is one PowerShell script that reads, checks, and writes the value
14
+ * without it ever leaving PowerShell.
8
15
  */
9
16
  import { spawnSync } from "node:child_process";
10
17
  import * as fs from "node:fs";
11
18
  import * as path from "node:path";
12
- const USER_ENV_KEY = "HKCU\\Environment";
13
- const MACHINE_ENV_KEY = "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
19
+ /** The longest user Path HQ will write. Longer values break `setx` and some installers. */
20
+ export const MAX_USER_PATH_LENGTH = 2047;
21
+ const REPLACEMENT_CHAR = "\uFFFD";
22
+ /**
23
+ * Tests on Windows point the scripts at a scratch key with this variable. The
24
+ * scripts accept only keys under HKCU:\Software\HQTest\ and skip the
25
+ * broadcast for them.
26
+ */
27
+ export const USER_PATH_TEST_KEY_ENV = "HQ_USER_PATH_TEST_KEY";
14
28
  /** The npm prefix folder above the package's `node_modules`, when `hq.cmd` is there. */
15
29
  export function npmShimDir(entry, exists) {
16
30
  if (!entry)
@@ -30,23 +44,37 @@ export function expandWindowsEnv(value, env) {
30
44
  return found === undefined ? whole : found;
31
45
  });
32
46
  }
33
- function regPath(run, key) {
34
- try {
35
- const result = run("reg.exe", ["query", key, "/v", "Path"]);
36
- if (result.status !== 0)
37
- return null;
38
- for (const line of result.stdout.split(/\r?\n/)) {
39
- // An empty value prints no text after the type, which is an empty Path,
40
- // not an unreadable one.
41
- const match = /^\s+Path\s+REG_(?:EXPAND_)?SZ(?:\s+(.*))?$/i.exec(line);
42
- if (match)
43
- return (match[1] ?? "").trim();
44
- }
45
- }
46
- catch {
47
- // reg.exe missing or refused: treat as unreadable.
47
+ const SELECT_USER_KEY = [
48
+ "$key = 'HKCU:\\Environment'",
49
+ `$test = $env:${USER_PATH_TEST_KEY_ENV}`,
50
+ "if ($test) { if (-not $test.StartsWith('HKCU:\\Software\\HQTest\\', [StringComparison]::OrdinalIgnoreCase)) { exit 2 }; $key = $test }",
51
+ ];
52
+ /**
53
+ * Prints `U:<base64>` for the unexpanded user Path and `M:<base64>` for the
54
+ * machine Path, base64 of the UTF-16LE bytes, or `U-` / `M-` when a value
55
+ * cannot be read. Base64 is ASCII, so the console code page cannot change it.
56
+ */
57
+ export const READ_PATHS_SCRIPT = [
58
+ "$ErrorActionPreference = 'Stop'",
59
+ ...SELECT_USER_KEY,
60
+ "function Emit($tag, $k) { $i = Get-Item -LiteralPath $k -ErrorAction SilentlyContinue; if ($null -eq $i) { return $tag + '-' }; $v = $i.GetValue('Path', $null, 'DoNotExpandEnvironmentNames'); if ($v -isnot [string]) { return $tag + '-' }; return $tag + ':' + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($v)) }",
61
+ "Write-Output (Emit 'U' $key)",
62
+ "Write-Output (Emit 'M' 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment')",
63
+ ].join("\n");
64
+ /** Decode READ_PATHS_SCRIPT output. Null for a value that could not be read. */
65
+ export function parseReadPathsOutput(stdout) {
66
+ const out = { user: null, machine: null };
67
+ for (const line of stdout.split(/\r?\n/)) {
68
+ const match = /^([UM]):([A-Za-z0-9+/=]*)$/.exec(line.trim());
69
+ if (!match)
70
+ continue;
71
+ const value = Buffer.from(match[2], "base64").toString("utf16le");
72
+ if (match[1] === "U")
73
+ out.user = value;
74
+ else
75
+ out.machine = value;
48
76
  }
49
- return null;
77
+ return out;
50
78
  }
51
79
  /**
52
80
  * The PATH a new terminal will get: the user Path then the machine Path, as
@@ -54,7 +82,17 @@ function regPath(run, key) {
54
82
  * be read.
55
83
  */
56
84
  export function readPersistedPath(run, env) {
57
- const values = [regPath(run, USER_ENV_KEY), regPath(run, MACHINE_ENV_KEY)].filter((v) => v !== null);
85
+ let values = [];
86
+ try {
87
+ const result = run(READ_PATHS_SCRIPT, {});
88
+ if (result.status === 0) {
89
+ const read = parseReadPathsOutput(result.stdout);
90
+ values = [read.user, read.machine].filter((v) => v !== null);
91
+ }
92
+ }
93
+ catch {
94
+ // PowerShell missing or refused: treat as unreadable.
95
+ }
58
96
  if (values.length === 0)
59
97
  return { value: env.PATH ?? env.Path ?? "", source: "process" };
60
98
  return { value: values.map((v) => expandWindowsEnv(v, env)).join(";"), source: "registry" };
@@ -69,8 +107,15 @@ export function isDirOnPath(dir, pathValue) {
69
107
  const want = normalizeDir(dir);
70
108
  return pathValue.split(";").some((entry) => normalizeDir(entry) === want);
71
109
  }
72
- function defaultRun(cmd, args) {
73
- const result = spawnSync(cmd, args, { encoding: "utf8", windowsHide: true, timeout: 5000 });
110
+ /** Runs a script through Windows PowerShell. The script travels as -EncodedCommand (UTF-16LE), so quoting cannot change it. */
111
+ function defaultPowerShell(script, extraEnv) {
112
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
113
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
114
+ encoding: "utf8",
115
+ windowsHide: true,
116
+ timeout: 15_000,
117
+ env: { ...process.env, ...extraEnv },
118
+ });
74
119
  return { status: result.status, stdout: result.stdout ?? "" };
75
120
  }
76
121
  /** Null when not on Windows or when hq was not installed as an npm global shim. */
@@ -80,7 +125,7 @@ export function checkShimOnPath(deps = {}) {
80
125
  const shimDir = npmShimDir(deps.entry ?? process.argv[1], deps.exists ?? fs.existsSync);
81
126
  if (!shimDir)
82
127
  return null;
83
- const persisted = readPersistedPath(deps.run ?? defaultRun, deps.env ?? process.env);
128
+ const persisted = readPersistedPath(deps.powerShell ?? defaultPowerShell, deps.env ?? process.env);
84
129
  return { shimDir, onPath: isDirOnPath(shimDir, persisted.value), source: persisted.source };
85
130
  }
86
131
  /** Dummy user variable set and cleared so Windows tells running programs that the environment changed. */
@@ -106,9 +151,125 @@ export function shimPathWarning(check) {
106
151
  return [];
107
152
  return [
108
153
  `The folder that holds the hq command (${check.shimDir}) is not on your Windows PATH, so a new terminal will not find hq.`,
109
- "To add it for your user, run this in PowerShell, then open a new terminal:",
154
+ "HQ could not add it automatically. To add it for your user, run this in PowerShell, then open a new terminal:",
110
155
  ` ${pathFixCommand(check.shimDir)}`,
111
- "HQ does not change PATH for you.",
112
156
  ];
113
157
  }
158
+ /**
159
+ * Decide the new unexpanded user Path. Membership is decided on the expanded
160
+ * value so `%APPDATA%\npm` counts. The stored text keeps `%VARS%`. A value
161
+ * that already holds U+FFFD was garbled by an earlier decode and is never
162
+ * written back. The ensure script applies the same rules inside PowerShell.
163
+ */
164
+ export function planUserPathAppend(current, dir, env) {
165
+ if (isDirOnPath(dir, expandWindowsEnv(current, env)))
166
+ return { action: "skip", reason: "present" };
167
+ if (current.includes(REPLACEMENT_CHAR) || dir.includes(REPLACEMENT_CHAR) || dir.includes(";")) {
168
+ return { action: "skip", reason: "unsafe-value" };
169
+ }
170
+ const trimmed = current.trim().replace(/;+$/g, "");
171
+ const value = trimmed ? `${trimmed};${dir}` : dir;
172
+ if (value.length > MAX_USER_PATH_LENGTH)
173
+ return { action: "skip", reason: "too-long" };
174
+ return { action: "write", value };
175
+ }
176
+ /** Exit codes of ENSURE_USER_PATH_SCRIPT. */
177
+ export const ENSURE_EXIT = {
178
+ written: 0,
179
+ badInput: 2,
180
+ unreadable: 3,
181
+ unsafeValue: 4,
182
+ tooLong: 5,
183
+ writtenNoBroadcast: 6,
184
+ present: 10,
185
+ };
186
+ /**
187
+ * Appends $env:HQ_SHIM_DIR to the HKCU Path as REG_EXPAND_SZ, then broadcasts
188
+ * the change. The value is read, checked, and written inside this script, so
189
+ * no console encoding touches it. Mirrors planUserPathAppend.
190
+ */
191
+ export const ENSURE_USER_PATH_SCRIPT = [
192
+ "$ErrorActionPreference = 'Stop'",
193
+ "$dir = $env:HQ_SHIM_DIR",
194
+ `if ([string]::IsNullOrWhiteSpace($dir) -or $dir.Contains(';') -or $dir.Contains([string][char]0xFFFD)) { exit ${ENSURE_EXIT.badInput} }`,
195
+ ...SELECT_USER_KEY,
196
+ "$item = Get-Item -LiteralPath $key -ErrorAction SilentlyContinue",
197
+ `if ($null -eq $item) { exit ${ENSURE_EXIT.unreadable} }`,
198
+ "$p = $item.GetValue('Path', '', 'DoNotExpandEnvironmentNames')",
199
+ `if ($p -isnot [string]) { exit ${ENSURE_EXIT.unsafeValue} }`,
200
+ // Same comparison as normalizeDir: unquote, use backslashes, collapse repeated
201
+ // separators (keeping a UNC prefix), drop trailing ones, ignore case.
202
+ "function Norm($s) { $t = $s.Trim(); if ($t.Length -ge 2 -and $t.StartsWith([string][char]34) -and $t.EndsWith([string][char]34)) { $t = $t.Substring(1, $t.Length - 2).Trim() }; $t = $t.Replace([char]47, [char]92); $bs = [string][char]92; $lead = ''; if ($t.StartsWith($bs + $bs)) { $lead = $bs + $bs; $t = $t.Substring(2) }; $t = $lead + [regex]::Replace($t, ($bs + $bs + '+'), $bs); return $t.TrimEnd([char]92).ToLowerInvariant() }",
203
+ "$want = Norm $dir",
204
+ `foreach ($e in [Environment]::ExpandEnvironmentVariables($p).Split(';')) { if ((Norm $e) -eq $want) { exit ${ENSURE_EXIT.present} } }`,
205
+ `if ($p.Contains([string][char]0xFFFD)) { exit ${ENSURE_EXIT.unsafeValue} }`,
206
+ "$t = $p.Trim().TrimEnd(';')",
207
+ "if ($t) { $new = $t + ';' + $dir } else { $new = $dir }",
208
+ `if ($new.Length -gt ${MAX_USER_PATH_LENGTH}) { exit ${ENSURE_EXIT.tooLong} }`,
209
+ "New-ItemProperty -LiteralPath $key -Name Path -PropertyType ExpandString -Force -Value $new | Out-Null",
210
+ `if (-not $test) { try { [Environment]::SetEnvironmentVariable('${PATH_REFRESH_VAR}','1','User'); [Environment]::SetEnvironmentVariable('${PATH_REFRESH_VAR}',$null,'User') } catch { exit ${ENSURE_EXIT.writtenNoBroadcast} } }`,
211
+ `exit ${ENSURE_EXIT.written}`,
212
+ ].join("\n");
213
+ export function ensureExitReason(status) {
214
+ switch (status) {
215
+ case ENSURE_EXIT.written:
216
+ case ENSURE_EXIT.writtenNoBroadcast:
217
+ return { written: true, reason: "written" };
218
+ case ENSURE_EXIT.present:
219
+ return { written: false, reason: "present" };
220
+ case ENSURE_EXIT.unreadable:
221
+ return { written: false, reason: "unreadable" };
222
+ case ENSURE_EXIT.unsafeValue:
223
+ case ENSURE_EXIT.badInput:
224
+ return { written: false, reason: "unsafe-value" };
225
+ case ENSURE_EXIT.tooLong:
226
+ return { written: false, reason: "too-long" };
227
+ default:
228
+ return { written: false, reason: "write-failed" };
229
+ }
230
+ }
231
+ export function powershellUserPathStore(run = defaultPowerShell, extraEnv = {}) {
232
+ return {
233
+ appendUserPath(dir) {
234
+ try {
235
+ return ensureExitReason(run(ENSURE_USER_PATH_SCRIPT, { ...extraEnv, HQ_SHIM_DIR: dir }).status);
236
+ }
237
+ catch {
238
+ return { written: false, reason: "write-failed" };
239
+ }
240
+ },
241
+ };
242
+ }
243
+ let cachedOnPathDir = null;
244
+ export function resetUserPathEnsureCacheForTests() {
245
+ cachedOnPathDir = null;
246
+ }
247
+ /**
248
+ * Add the npm shim folder to HKCU Path when it is missing. Null off Windows
249
+ * or when this process is not an npm global install. A failed read does not
250
+ * write. Machine Path is never written.
251
+ */
252
+ export function ensureShimOnUserPath(deps = {}) {
253
+ const platform = deps.platform ?? process.platform;
254
+ if (platform !== "win32")
255
+ return null;
256
+ const cacheable = deps.store === undefined && deps.powerShell === undefined && deps.platform === undefined;
257
+ if (cacheable && cachedOnPathDir) {
258
+ return { shimDir: cachedOnPathDir, written: false, onPath: true, source: "registry", reason: "present" };
259
+ }
260
+ const check = checkShimOnPath(deps);
261
+ if (!check)
262
+ return null;
263
+ if (check.onPath) {
264
+ if (cacheable)
265
+ cachedOnPathDir = check.shimDir;
266
+ return { shimDir: check.shimDir, written: false, onPath: true, source: check.source, reason: "present" };
267
+ }
268
+ const store = deps.store ?? powershellUserPathStore(deps.powerShell);
269
+ const applied = store.appendUserPath(check.shimDir);
270
+ const onPath = applied.written || applied.reason === "present";
271
+ if (onPath && cacheable)
272
+ cachedOnPathDir = check.shimDir;
273
+ return { shimDir: check.shimDir, written: applied.written, onPath, source: check.source, reason: applied.reason };
274
+ }
114
275
  //# sourceMappingURL=user-path.js.map
package/dist/main.js CHANGED
@@ -48,6 +48,7 @@ import { enforceVersionGate, getVersionGatePhase, isLocalDependencyInstall, reso
48
48
  import { autoUpdateAndReexec } from "./utils/self-update.js";
49
49
  import { CLI_VERSION } from "./cli-version.js";
50
50
  import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
51
+ import { argvWithoutHelpOnUnknownCommand } from "./unknown-command.js";
51
52
  import { emitCliFirstSyncNextStepOutcome, emitCliFeatureUsed, emitCliLimitNoticeEngagement, emitCliLimitNoticeExposure, emitCliInviteNudgeEvent, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, isCliLimitResourceKind, queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, takeFirstSyncStepOutcomes, takeCliFeatureUsages, takeCliLimitNoticeEngagements, takeCliLimitNoticeExposures, takeCliInviteNudgeEvents, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
52
53
  import { reportCliClientHealthInvocation } from "./utils/client-health.js";
53
54
  import { settleWithin } from "./utils/settle-with-timeout.js";
@@ -308,7 +309,7 @@ export async function runCli() {
308
309
  return;
309
310
  }
310
311
  await releasePackageUseLeaseAfterCommandLoad(process.argv);
311
- await program.parseAsync();
312
+ await program.parseAsync(argvWithoutHelpOnUnknownCommand(process.argv));
312
313
  commandParsed = true;
313
314
  if (process.exitCode === undefined || process.exitCode === 0) {
314
315
  maybeShowFirstSyncNextStep();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `hq invite --help` is not a root command. Commander treats `--help` as a
3
+ * request for the parent help and exits 0 before unknownCommand(). Strip help
4
+ * flags when the first command word is not a root, then append a nested
5
+ * did-you-mean when Commander has not already suggested one.
6
+ * A hidden root such as `daemon` is still a real command, so its `--help` stays.
7
+ */
8
+ import { Command } from "commander";
9
+ export declare function firstCommandToken(argv: readonly string[]): string | undefined;
10
+ export declare function isKnownRootCommand(token: string): boolean;
11
+ /** Drop `--help`/`-h` so an unknown first word is reported instead of printing root help. */
12
+ export declare function argvWithoutHelpOnUnknownCommand(argv: readonly string[]): string[];
13
+ /** Nested `parent child` paths whose command name or alias is `token`. Hidden commands are skipped. */
14
+ export declare function suggestNestedCommands(token: string): string[];
15
+ export declare function augmentUnknownCommandError(text: string): string;
16
+ /** Wrap the current stderr writer once. A bare unknown-command line gains a nested suggestion. */
17
+ export declare function installUnknownCommandSuggestions(program: Command): void;
18
+ //# sourceMappingURL=unknown-command.d.ts.map
@@ -0,0 +1,88 @@
1
+ import { COMMAND_CATALOG } from "./command-catalog.generated.js";
2
+ const SUGGEST = Symbol.for("hq.unknownCommandSuggest");
3
+ const ANSI = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-?]*[ -/]*[@-~]`, "g");
4
+ export function firstCommandToken(argv) {
5
+ const args = argv.slice(2);
6
+ for (const token of args) {
7
+ if (token === "--")
8
+ break;
9
+ if (token.startsWith("-"))
10
+ continue;
11
+ return token;
12
+ }
13
+ return undefined;
14
+ }
15
+ export function isKnownRootCommand(token) {
16
+ const catalog = COMMAND_CATALOG;
17
+ // Hidden roots are omitted from help, but they are still commands. Stripping
18
+ // `--help` from `hq daemon remote-control-pools --help` makes Commander print
19
+ // that command's usage to stderr and exit 1.
20
+ return catalog.some((command) => command.name === token || command.aliases.includes(token));
21
+ }
22
+ /** Drop `--help`/`-h` so an unknown first word is reported instead of printing root help. */
23
+ export function argvWithoutHelpOnUnknownCommand(argv) {
24
+ const token = firstCommandToken(argv);
25
+ if (!token || token === "help" || isKnownRootCommand(token))
26
+ return [...argv];
27
+ const out = [];
28
+ let passthrough = false;
29
+ argv.forEach((arg, index) => {
30
+ if (index < 2) {
31
+ out.push(arg);
32
+ return;
33
+ }
34
+ if (arg === "--")
35
+ passthrough = true;
36
+ if (!passthrough && (arg === "--help" || arg === "-h"))
37
+ return;
38
+ out.push(arg);
39
+ });
40
+ return out;
41
+ }
42
+ /** Nested `parent child` paths whose command name or alias is `token`. Hidden commands are skipped. */
43
+ export function suggestNestedCommands(token) {
44
+ const hits = new Set();
45
+ const walk = (entries, prefix) => {
46
+ for (const entry of entries) {
47
+ if (entry.hidden)
48
+ continue;
49
+ const next = [...prefix, entry.name];
50
+ if (prefix.length > 0 && (entry.name === token || entry.aliases.includes(token)))
51
+ hits.add(next.join(" "));
52
+ if (entry.subcommands.length > 0)
53
+ walk(entry.subcommands, next);
54
+ }
55
+ };
56
+ walk(COMMAND_CATALOG, []);
57
+ return [...hits].sort((a, b) => a.localeCompare(b));
58
+ }
59
+ export function augmentUnknownCommandError(text) {
60
+ const plain = text.replace(ANSI, "");
61
+ const match = /^error: unknown command '([^']+)'\n$/.exec(plain);
62
+ if (!match)
63
+ return text;
64
+ const suggestions = suggestNestedCommands(match[1]);
65
+ if (suggestions.length === 0)
66
+ return text;
67
+ const hint = suggestions.length === 1
68
+ ? `(Did you mean ${suggestions[0]}?)`
69
+ : `(Did you mean one of ${suggestions.join(", ")}?)`;
70
+ return `${text}${hint}\n`;
71
+ }
72
+ /** Wrap the current stderr writer once. A bare unknown-command line gains a nested suggestion. */
73
+ export function installUnknownCommandSuggestions(program) {
74
+ const tagged = program;
75
+ if (tagged[SUGGEST])
76
+ return;
77
+ tagged[SUGGEST] = true;
78
+ const config = program._outputConfiguration;
79
+ const base = config?.writeErr ?? ((str) => {
80
+ process.stderr.write(str);
81
+ });
82
+ program.configureOutput({
83
+ writeErr(str) {
84
+ base(augmentUnknownCommandError(str));
85
+ },
86
+ });
87
+ }
88
+ //# sourceMappingURL=unknown-command.js.map
@@ -119,6 +119,14 @@ export type EnsureCognitoTokenOptions = {
119
119
  /** Cancels bounded, non-interactive person-session refreshes. */
120
120
  signal?: AbortSignal;
121
121
  };
122
+ /**
123
+ * Env switch a parent process sets so a child `hq` never opens a browser
124
+ * sign-in. A hook-driven helper (SessionStart's refresh-vault-access) cannot
125
+ * complete a browser login, and a child waiting on one outlives its caller.
126
+ */
127
+ export declare const HQ_NO_BROWSER_LOGIN_ENV = "HQ_NO_BROWSER_LOGIN";
128
+ /** An explicit `interactive` option wins; otherwise interactive unless the env switch is "1". */
129
+ export declare function resolveInteractiveLogin(options: Pick<EnsureCognitoTokenOptions, "interactive">, env?: NodeJS.ProcessEnv): boolean;
122
130
  /**
123
131
  * True when a usable person Cognito cache file exists under the effective
124
132
  * `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the