@indigoai-us/hq-cli 5.331.0 → 5.332.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.332.0] — 2026-10-02
6
+ - Match macOS shell behavior in native core commands for text, diagnostics, and file modes.
7
+ - `hq mcp status` now recognizes the real Claude plugin MCP definition and Codex PATH commands, while global and `hq-anywhere` pack installs share one Codex hook per event and one `hq` MCP server.
8
+
5
9
  ## [5.331.0] — 2026-10-02
6
10
  - Prevent duplicate Claude hook dispatch inside the HQ root when global hooks are installed.
7
11
 
@@ -51,6 +51,33 @@ done
51
51
  [ "${#DIRS[@]}" -gt 0 ] || DIRS=("$HQ_ROOT/personal/policies" "$HQ_ROOT/core/policies")
52
52
  command -v jq >/dev/null 2>&1 || { echo "policy-age-report: jq required" >&2; exit 2; }
53
53
  NOW_EPOCH="${HQ_POLICY_REPORT_NOW_EPOCH:-$(date +%s)}"
54
+ if [ -n "${HQ_POLICY_REPORT_NOW_EPOCH:-}" ]; then
55
+ CLOCK_INPUT="$HQ_POLICY_REPORT_NOW_EPOCH"
56
+ if [[ ! "$CLOCK_INPUT" =~ ^[+-]?[0-9]+$ ]]; then
57
+ printf 'policy-age-report: HQ_POLICY_REPORT_NOW_EPOCH must be an integer: %s\n' "$CLOCK_INPUT" >&2
58
+ exit 1
59
+ fi
60
+ CLOCK_NEGATIVE=0
61
+ CLOCK_DIGITS="$CLOCK_INPUT"
62
+ case "$CLOCK_DIGITS" in
63
+ -*) CLOCK_NEGATIVE=1; CLOCK_DIGITS="${CLOCK_DIGITS#-}" ;;
64
+ +*) CLOCK_DIGITS="${CLOCK_DIGITS#+}" ;;
65
+ esac
66
+ while [[ "${#CLOCK_DIGITS}" -gt 1 && "${CLOCK_DIGITS:0:1}" = 0 ]]; do
67
+ CLOCK_DIGITS="${CLOCK_DIGITS:1}"
68
+ done
69
+ CLOCK_LIMIT=9223372036854775807
70
+ [ "$CLOCK_NEGATIVE" = 1 ] && CLOCK_LIMIT=9223372036854775808
71
+ if [[ "${#CLOCK_DIGITS}" -gt "${#CLOCK_LIMIT}" ]] || { [[ "${#CLOCK_DIGITS}" -eq "${#CLOCK_LIMIT}" ]] && (LC_ALL=C; [[ "$CLOCK_DIGITS" > "$CLOCK_LIMIT" ]]); }; then
72
+ printf 'policy-age-report: HQ_POLICY_REPORT_NOW_EPOCH must be an integer: %s\n' "$CLOCK_INPUT" >&2
73
+ exit 1
74
+ fi
75
+ if [ "$CLOCK_NEGATIVE" = 1 ]; then
76
+ NOW_EPOCH=$((0 - 10#$CLOCK_DIGITS))
77
+ else
78
+ NOW_EPOCH=$((10#$CLOCK_DIGITS))
79
+ fi
80
+ fi
54
81
  TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
55
82
 
56
83
  # One pass over every ledger: slug<TAB>mtime, then reduce to count + max mtime.
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { runBundledScript } from "../utils/run-bundled-script.js";
5
+ import { isByteOrientedLocale } from "../lib/core-utils/byte-oriented-locale.js";
5
6
  export const DERIVE_TRIGGER_FACTS_ASSET = "core/scripts/derive-trigger-facts.sh";
6
7
  const TEXT_FACT_AWK = "core/scripts/lib/trigger-fact-text.awk";
7
8
  const TRANSCRIPT_TAIL_MAX_BYTES = 1_048_576;
@@ -19,8 +20,7 @@ function asciiLower(value) {
19
20
  return value.replace(/[A-Z]/g, (letter) => String.fromCharCode(letter.charCodeAt(0) + 32));
20
21
  }
21
22
  function localeLower(value, env) {
22
- const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "C";
23
- if (/^(?:C|POSIX)(?:$|\.(?!utf-?8$))/i.test(locale))
23
+ if (isByteOrientedLocale(env, process.platform))
24
24
  return asciiLower(value);
25
25
  // glibc applies towlower independently to each character. Lowercase code
26
26
  // points separately so JavaScript does not apply context-sensitive sigma.
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
+ import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
6
7
  export const HQ_SESSION_ASSET = "core/scripts/hq-session.sh";
7
8
  export const HQ_SESSION_HELP = [
8
9
  "hq-core: public",
@@ -483,7 +484,7 @@ export function executeHqSession(argv, context, overrides = {}) {
483
484
  }
484
485
  catch (error) {
485
486
  if (error?.code === "EACCES" || error?.code === "EPERM") {
486
- return { code: 2, stdout: "", stderr: `awk: fatal: cannot open file \`${metaPath}' for reading: Permission denied\n` };
487
+ return { code: 2, stdout: "", stderr: awkCannotOpenFileDiagnostic(metaPath, "Permission denied", 6, process.platform, deps.env.PATH) };
487
488
  }
488
489
  if (isExpectedFsMiss(error))
489
490
  return { code: 1, stdout: "", stderr: `awk: cannot open ${metaPath} (Permission denied)\n` };
@@ -5,6 +5,7 @@ import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { evaluateTrigger } from "../utils/core-eval-trigger.js";
8
+ import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
8
9
  export const MIGRATE_POLICY_TRIGGERS_ASSET = "core/scripts/migrate-policy-triggers.sh";
9
10
  const ON_LIVE = "[PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent]";
10
11
  const ON_START = "[SessionStart]";
@@ -151,9 +152,11 @@ function deriveWhen(trigger, rawTags, enforcementRaw) {
151
152
  }
152
153
  function writeAtomic(fileSystem, target, bytes) {
153
154
  const temporary = path.join(path.dirname(target), `.migrate-policy-triggers.${process.pid}.${Math.random().toString(16).slice(2)}`);
155
+ // The shell writes through mktemp, whose private mode survives the rename on both platforms.
156
+ const mode = 0o600;
154
157
  try {
155
- fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", mode: 0o600, flag: "wx" });
156
- fileSystem.chmodSync(temporary, 0o600);
158
+ fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", mode, flag: "wx" });
159
+ fileSystem.chmodSync(temporary, mode);
157
160
  fileSystem.renameSync(temporary, target);
158
161
  return true;
159
162
  }
@@ -165,11 +168,11 @@ function writeAtomic(fileSystem, target, bytes) {
165
168
  return false;
166
169
  }
167
170
  }
168
- function unreadablePolicyDiagnostic(filename, error) {
171
+ function unreadablePolicyDiagnostic(filename, error, pathValue) {
169
172
  const code = error.code;
170
173
  const message = code === "EACCES" || code === "EPERM" ? "Permission denied" : "No such file or directory";
171
174
  const script = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/scaffold/core/scripts/migrate-policy-triggers.sh");
172
- const awk = `awk: fatal: cannot open file \`${filename}' for reading: ${message}\n`;
175
+ const awk = awkCannotOpenFileDiagnostic(filename, message, 1, process.platform, pathValue);
173
176
  return `${script}: line 314: ${filename}: ${message}\n${awk.repeat(3)}`;
174
177
  }
175
178
  function shellGlobMd(fileSystem, directory) {
@@ -293,7 +296,7 @@ export function runMigratePolicyTriggers(args, deps) {
293
296
  original = fileSystem.readFileSync(filename, "utf8");
294
297
  }
295
298
  catch (error) {
296
- stderr += unreadablePolicyDiagnostic(filename, error);
299
+ stderr += unreadablePolicyDiagnostic(filename, error, deps.env.PATH);
297
300
  untriggered += 1;
298
301
  continue;
299
302
  }
@@ -8,6 +8,7 @@ import { resolveLiveRoot, resolveBundledAsset } from '../utils/hq-roots.js';
8
8
  import { runBundledScript } from '../utils/run-bundled-script.js';
9
9
  import { describeCoreCommand } from './core-command-catalog.js';
10
10
  import { runPolicyFrontmatter as runPolicyFrontmatterImpl } from './core-policy-frontmatter.js';
11
+ import { awkCannotOpenFileDiagnostic } from '../lib/core-utils/awk-diagnostics.js';
11
12
  const POLICY_AGE_REPORT_ASSET = 'core/scripts/policy-age-report.sh';
12
13
  const POLICY_RETIRE_ASSET = 'core/scripts/policy-retire.sh';
13
14
  const POLICY_FRONTMATTER_ASSET = 'core/scripts/read-policy-frontmatter.sh';
@@ -266,7 +267,7 @@ function epochFromDate(value) {
266
267
  return 0;
267
268
  return Math.floor(parsed / 1000);
268
269
  }
269
- function countLedgerRows(directory, withMtime, recursive) {
270
+ function countLedgerRows(directory, withMtime, recursive, awkPath) {
270
271
  const counts = new Map();
271
272
  const newest = new Map();
272
273
  let anyFiles = false;
@@ -299,7 +300,7 @@ function countLedgerRows(directory, withMtime, recursive) {
299
300
  catch (error) {
300
301
  const code = error.code;
301
302
  if (withMtime && (code === 'EACCES' || code === 'EPERM')) {
302
- stderr += `awk: fatal: cannot open file \`${full}' for reading: Permission denied\n`;
303
+ stderr += awkCannotOpenFileDiagnostic(full, 'Permission denied', 1, process.platform, awkPath);
303
304
  continue;
304
305
  }
305
306
  if (expectedFsMiss(error))
@@ -391,12 +392,11 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
391
392
  const epoch = bashInteger(env.HQ_POLICY_REPORT_NOW_EPOCH);
392
393
  if (epoch === undefined) {
393
394
  const value = env.HQ_POLICY_REPORT_NOW_EPOCH;
394
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
395
- throw new Error(`${resolveBundledAsset(POLICY_AGE_REPORT_ASSET)}: line 96: ${value}: unbound variable`);
396
- }
397
- throw new Error(`${resolveBundledAsset(POLICY_AGE_REPORT_ASSET)}: line 96: ${value}: invalid arithmetic value`);
395
+ throw new Error(`policy-age-report: HQ_POLICY_REPORT_NOW_EPOCH must be an integer: ${value}`);
396
+ }
397
+ else {
398
+ now = Number(epoch);
398
399
  }
399
- now = Number(epoch);
400
400
  }
401
401
  }
402
402
  catch (error) {
@@ -404,8 +404,9 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
404
404
  }
405
405
  const ledgerDir = env.HQ_POLICY_LEDGER_DIR || path.join(root, 'workspace/orchestrator/policy-trigger-state');
406
406
  const retrievalDir = env.HQ_POLICY_RETRIEVAL_LEDGER_DIR || path.join(root, 'workspace/orchestrator/policy-retrieval-state');
407
- const usage = countLedgerRows(ledgerDir, true, true);
408
- const retrieval = countLedgerRows(retrievalDir, false, false);
407
+ const awkPath = env.PATH ?? '/usr/bin:/bin';
408
+ const usage = countLedgerRows(ledgerDir, true, true, awkPath);
409
+ const retrieval = countLedgerRows(retrievalDir, false, false, awkPath);
409
410
  const dirs = parsed.dirs.length > 0 ? parsed.dirs : [path.join(root, 'personal/policies'), path.join(root, 'core/policies')];
410
411
  const rows = [];
411
412
  let stderr = usage.stderr + retrieval.stderr;
@@ -439,13 +440,13 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
439
440
  const singleIncident = ['user-correction', 'session-learning', 'back-pressure-failure'].includes(source) && (version || '1') === '1';
440
441
  const classes = [];
441
442
  if (fired === 0) {
442
- const comparison = bashTestGreaterOrEqual(ageDays, parsed.minAge, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 130);
443
+ const comparison = bashTestGreaterOrEqual(ageDays, parsed.minAge, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 157);
443
444
  stderr += comparison.stderr;
444
445
  if (comparison.value && usage.anyFiles)
445
446
  classes.push('never-fired');
446
447
  }
447
448
  if (fired > 0) {
448
- const comparison = bashTestGreaterOrEqual(lastAge, parsed.dormantDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 131);
449
+ const comparison = bashTestGreaterOrEqual(lastAge, parsed.dormantDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 158);
449
450
  stderr += comparison.stderr;
450
451
  if (comparison.value)
451
452
  classes.push('dormant');
@@ -455,7 +456,7 @@ export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Mat
455
456
  if (refs.renamed.length > 0)
456
457
  classes.push('renamed-refs');
457
458
  if (singleIncident) {
458
- const comparison = bashTestGreaterOrEqual(ageDays, parsed.incidentDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 135);
459
+ const comparison = bashTestGreaterOrEqual(ageDays, parsed.incidentDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 162);
459
460
  stderr += comparison.stderr;
460
461
  if (comparison.value && fired <= 1)
461
462
  classes.push('single-incident-old');
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
+ import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
5
6
  export const RESOLVE_COMPANY_ASSET = "core/scripts/resolve-company.sh";
6
7
  export const RESOLVE_COMPANY_HELP = [
7
8
  "hq-core: public",
@@ -36,7 +37,7 @@ function readFile(filePath, fileSystem) {
36
37
  throw error;
37
38
  }
38
39
  }
39
- function readManifest(filePath, fileSystem) {
40
+ function readManifest(filePath, fileSystem, pathValue) {
40
41
  try {
41
42
  return fileSystem.readFileSync(filePath, "utf8");
42
43
  }
@@ -46,7 +47,7 @@ function readManifest(filePath, fileSystem) {
46
47
  return {
47
48
  code: 0,
48
49
  stdout: Buffer.from('{"company":"","source":"none"}\n'),
49
- stderr: Buffer.from("awk: fatal: cannot open file `" + filePath + "' for reading: Permission denied\n"),
50
+ stderr: Buffer.from(awkCannotOpenFileDiagnostic(filePath, "Permission denied", 1, process.platform, pathValue)),
50
51
  };
51
52
  }
52
53
  if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP")
@@ -340,7 +341,7 @@ export async function resolveCompany(args, dependencies) {
340
341
  const manifest = path.join(root, "companies/manifest.yaml");
341
342
  if (!isFile(manifest, fileSystem))
342
343
  return output("", "none");
343
- const manifestResult = readManifest(manifest, fileSystem);
344
+ const manifestResult = readManifest(manifest, fileSystem, env.PATH ?? "/usr/bin:/bin");
344
345
  if (typeof manifestResult !== "string")
345
346
  return manifestResult;
346
347
  const manifestText = manifestResult;
@@ -13,6 +13,7 @@ export type ShareSuggestionDependencies = {
13
13
  fs?: Pick<typeof fs, "existsSync" | "statSync" | "readFileSync" | "writeFileSync" | "appendFileSync" | "renameSync" | "unlinkSync" | "mkdirSync">;
14
14
  now?: () => Date;
15
15
  stdinIsTty?: boolean;
16
+ platform?: NodeJS.Platform;
16
17
  };
17
18
  export declare function runShareSuggestionState(args: readonly string[], stdin: string, deps: ShareSuggestionDependencies): HookHelperOutput;
18
19
  //# sourceMappingURL=core-share-suggestion-state-native.d.ts.map
@@ -1,6 +1,7 @@
1
1
  /** Native port of share-suggestion-state.sh; kept dependency-light for hooks. */
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { isByteOrientedLocale } from "../lib/core-utils/byte-oriented-locale.js";
4
5
  export const SHARE_SUGGESTION_STATE_ASSET = "core/scripts/share-suggestion-state.sh";
5
6
  const isObj = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
7
  const sortKeys = (value) => Array.isArray(value) ? value.map(sortKeys)
@@ -148,6 +149,7 @@ export function runShareSuggestionState(args, stdin, deps) {
148
149
  if (!root)
149
150
  return failFromShellMain("share-suggestion-state: unable to resolve HQ root\n");
150
151
  const { fileSystem: f, env, isoNow } = makeContext(deps);
152
+ const platform = deps.platform ?? process.platform;
151
153
  const stateHome = path.join(root, "workspace/orchestrator/share-suggestions");
152
154
  const historyPath = path.join(stateHome, "history.jsonl");
153
155
  const suppressionsPath = path.join(stateHome, "suppressions.jsonl");
@@ -169,10 +171,20 @@ export function runShareSuggestionState(args, stdin, deps) {
169
171
  catch { /* locate the first existing non-directory parent when present */ }
170
172
  }
171
173
  const reason = code === "ENOTDIR" ? "Not a directory" : code === "EACCES" || code === "EPERM" ? "Permission denied" : code === "EEXIST" ? "File exists" : (error instanceof Error ? error.message : String(error));
172
- return failFromShellMain(`mkdir: cannot create directory ‘${blockedPath}’: ${reason}\nshare-suggestion-state: unable to create state directory\n`);
174
+ const mkdirError = platform === "darwin"
175
+ ? `mkdir: ${blockedPath}: ${reason}\n`
176
+ : `mkdir: cannot create directory ‘${blockedPath}’: ${reason}\n`;
177
+ return failFromShellMain(`${mkdirError}share-suggestion-state: unable to create state directory\n`);
173
178
  }
174
- const sessionBytes = Buffer.from(rawSession, "utf8");
175
- const sessionId = Array.from(sessionBytes, (byte) => byte === 45 || byte === 95 || (byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) ? String.fromCharCode(byte) : "_").join("") || "unknown";
179
+ const sessionUnits = platform === "darwin" && !isByteOrientedLocale(env, platform)
180
+ ? Array.from(rawSession)
181
+ : Buffer.from(rawSession, "utf8");
182
+ const sessionId = Array.from(sessionUnits, (unit) => {
183
+ const code = typeof unit === "number" ? unit : unit.codePointAt(0);
184
+ return code === 45 || code === 95 || (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
185
+ ? String.fromCharCode(code)
186
+ : "_";
187
+ }).join("") || "unknown";
176
188
  const pendingPath = path.join(stateHome, `${sessionId}.json`);
177
189
  // Bash command substitution removes every trailing LF before exporting the payload.
178
190
  const payloadRaw = deps.stdinIsTty ? "" : stdin.replace(/\n+$/, "");
@@ -11,7 +11,7 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { spawnSync } from 'node:child_process';
14
- import { buildCodexServerDef, claudeConfigFormat, codexConfigFormat, isCodexInstalled, jsonFormat, mergeCodexServer, registerCodexServer, unregisterCodexServer, mergeClaudeServer, readConfigDoc, realpathOrSelf, registerClaudeServer, resolveEnv, unregisterClaudeServer, writeConfigAtomic, } from './mcp-registration.js';
14
+ import { buildCodexServerDef, claudeConfigFormat, codexConfigFormat, codexRemovalFormat, isCodexInstalled, jsonFormat, mergeCodexServer, removeCodexServer, registerCodexServer, unregisterCodexServer, mergeClaudeServer, readConfigDoc, realpathOrSelf, registerClaudeServer, resolveEnv, unregisterClaudeServer, writeConfigAtomic, } from './mcp-registration.js';
15
15
  import { seedRegistryFromHqRoot } from './link.js';
16
16
  import { resolveHqRoot } from './doctor.js';
17
17
  import { anywhereRuntimeEnabled, AnywhereRuntimeDisabledError } from './anywhere-runtime-flag.js';
@@ -685,8 +685,14 @@ export function installGlobalCodex(opts) {
685
685
  const hooksNext = addCodexHooks(hooksDoc, hqRoot, hookTarget);
686
686
  const tomlOld = readOrNull(t.configToml);
687
687
  const tomlDoc = readConfigDoc(t.configToml, codexConfigFormat).doc;
688
+ const codexServers = tomlDoc.value.mcp_servers;
689
+ const legacyPackServerPresent = codexServers?.['hq-anywhere']?._hqPack === HQ_ANYWHERE_PACK;
688
690
  // Collision check runs here too, so a dry run surfaces a conflicting "hq" server.
689
- const tomlNext = mergeCodexServer(HQ_MCP_SERVER_NAME, buildCodexServerDef(manifest, HQ_ANYWHERE_PACK, () => null, new Set()))(tomlDoc);
691
+ const codexDef = buildCodexServerDef(manifest, HQ_ANYWHERE_PACK, () => null, new Set());
692
+ const migrationBase = legacyPackServerPresent
693
+ ? codexConfigFormat.parse(codexRemovalFormat.serialize(removeCodexServer('hq-anywhere', HQ_ANYWHERE_PACK)(tomlDoc)))
694
+ : tomlDoc;
695
+ const tomlNext = mergeCodexServer(HQ_MCP_SERVER_NAME, codexDef)(migrationBase);
690
696
  if (pointerOld !== pointerNew)
691
697
  touched.push(hqPointer);
692
698
  if (mdOld !== mdNew)
@@ -697,7 +703,7 @@ export function installGlobalCodex(opts) {
697
703
  touched.push(path.join(t.skillsDir, `${SKILL_LINK_PREFIX}${s}`));
698
704
  if (hooksNext !== hooksDoc)
699
705
  touched.push(t.hooksJson);
700
- if (tomlNext !== tomlDoc)
706
+ if (tomlNext !== tomlDoc || legacyPackServerPresent)
701
707
  touched.push(t.configToml);
702
708
  touched.push(regFile, t.stateFile);
703
709
  if (opts.dryRun) {
@@ -746,6 +752,9 @@ export function installGlobalCodex(opts) {
746
752
  assertName: 'hooks',
747
753
  env: { home },
748
754
  });
755
+ if (legacyPackServerPresent) {
756
+ unregisterCodexServer({ name: 'hq-anywhere', pack: HQ_ANYWHERE_PACK, env: { home } });
757
+ }
749
758
  registerCodexServer({ name: HQ_MCP_SERVER_NAME, manifest, pack: HQ_ANYWHERE_PACK, env: { home } });
750
759
  let seeded = [];
751
760
  let ownedSeeded = [];
@@ -792,14 +801,15 @@ export function uninstallGlobalCodex(opts = {}) {
792
801
  throw new Error('No HQ global install found (no install state and no ~/.hq/root).');
793
802
  const regFile = opts.registryFile ?? registryFile(registryDir(home, env));
794
803
  const claudeStillInstalled = fs.existsSync(claudeTargets(home).stateFile);
804
+ const packStillInstalled = fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-anywhere', 'package.yaml'));
795
805
  const touched = [];
796
806
  const tomlDoc = readConfigDoc(t.configToml, codexConfigFormat).doc;
797
807
  const servers = tomlDoc.value.mcp_servers;
798
- if (servers?.[HQ_MCP_SERVER_NAME]?._hqPack === HQ_ANYWHERE_PACK)
808
+ if (!packStillInstalled && servers?.[HQ_MCP_SERVER_NAME]?._hqPack === HQ_ANYWHERE_PACK)
799
809
  touched.push(t.configToml);
800
810
  const hooksOriginal = state ? state.hooks.original : null;
801
811
  const hooksDoc = readConfigDoc(t.hooksJson, jsonFormat).doc;
802
- const hooksNext = removeCodexHooks(hooksDoc, hqRoot);
812
+ const hooksNext = packStillInstalled ? hooksDoc : removeCodexHooks(hooksDoc, hqRoot);
803
813
  if (hooksNext !== hooksDoc)
804
814
  touched.push(t.hooksJson);
805
815
  const source = hqSkillsSource(hqRoot);
@@ -848,7 +858,8 @@ export function uninstallGlobalCodex(opts = {}) {
848
858
  log(lineDiff(jsonFormat.serialize(hooksDoc), jsonFormat.serialize(hooksNext)));
849
859
  return touched;
850
860
  }
851
- unregisterCodexServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
861
+ if (!packStillInstalled)
862
+ unregisterCodexServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
852
863
  // Removal re-serializes the TOML (dropping comments). When what is left equals
853
864
  // the pre-install document, put the original bytes back.
854
865
  const tomlOriginal = state?.configToml.original ?? null;
@@ -858,12 +869,12 @@ export function uninstallGlobalCodex(opts = {}) {
858
869
  writeFileAtomic(realpathOrSelf(t.configToml), tomlOriginal);
859
870
  }
860
871
  }
861
- if (fs.existsSync(t.hooksJson)) {
872
+ if (!packStillInstalled && fs.existsSync(t.hooksJson)) {
862
873
  writeConfigAtomic({
863
874
  target: t.hooksJson,
864
875
  pack: HQ_ANYWHERE_PACK,
865
- format: restoringRemovalFormat(hooksOriginal, (doc) => removeCodexHooks(doc, hqRoot) !== doc),
866
- merge: (doc) => removeCodexHooks(doc, hqRoot),
876
+ format: restoringRemovalFormat(hooksOriginal, (doc) => (packStillInstalled ? doc : removeCodexHooks(doc, hqRoot)) !== doc),
877
+ merge: (doc) => packStillInstalled ? doc : removeCodexHooks(doc, hqRoot),
867
878
  assertName: 'hooks',
868
879
  env: { home },
869
880
  });
@@ -529,6 +529,8 @@ export interface RegisterServerOptions {
529
529
  * injected `env.home`, so in tests it lands in the tmpdir — never the real ~/.
530
530
  */
531
531
  audit?: boolean;
532
+ /** Preserve the globally installed Codex HQ endpoint while still registering Claude. */
533
+ preserveCodex?: boolean;
532
534
  }
533
535
  /** Outcome of registering one server into one surface. */
534
536
  export interface RegisterSurfaceResult {
@@ -645,6 +647,8 @@ export declare function registerMcpServers(pkg: string, names: string[], options
645
647
  * Omitted → read `HQ_DISABLE_MCP_REGISTRATION` directly.
646
648
  */
647
649
  registrationEnabled?: boolean;
650
+ /** Preserve the global Codex HQ endpoint when the same pack also wires Claude. */
651
+ preserveCodex?: boolean;
648
652
  }): RegisterServerResult[];
649
653
  /** smol-toml's value-table type (its `parse` return + `stringify` input). */
650
654
  type TomlTable = Record<string, unknown>;
@@ -825,6 +829,8 @@ export interface UnregisterServerOptions {
825
829
  * that call {@link unregisterClaudeServer}/{@link unregisterCodexServer} directly.
826
830
  */
827
831
  audit?: boolean;
832
+ /** Keep the shared Codex hq endpoint while the global installer still owns it. */
833
+ preserveCodex?: boolean;
828
834
  }
829
835
  /**
830
836
  * Un-register ONE server from the PINNED single Claude surface (top-level
@@ -924,6 +930,7 @@ export declare function unregisterMcpServers(pkg: string, names: string[], optio
924
930
  /** Lock tuning + backup stamp passthrough (tests). */
925
931
  lock?: AcquireLockOptions;
926
932
  stamp?: string;
933
+ preserveCodex?: boolean;
927
934
  }): UnregisterServerResult[];
928
935
  export {};
929
936
  //# sourceMappingURL=mcp-registration.d.ts.map
@@ -1177,7 +1177,9 @@ export function registerServer(opts) {
1177
1177
  const codexPrevHash = audit ? hashFileContents(codexTarget) : '';
1178
1178
  let codex;
1179
1179
  try {
1180
- codex = registerCodexServer(opts);
1180
+ codex = opts.preserveCodex
1181
+ ? { skipped: true, reason: 'shared global install owns Codex hq endpoint' }
1182
+ : registerCodexServer(opts);
1181
1183
  }
1182
1184
  catch (e) {
1183
1185
  if (audit) {
@@ -1302,6 +1304,7 @@ export function registerMcpServers(pkg, names, options) {
1302
1304
  env: options?.env,
1303
1305
  lock: options?.lock,
1304
1306
  stamp: options?.stamp,
1307
+ preserveCodex: options?.preserveCodex && pkg === 'hq-anywhere' && name === 'hq',
1305
1308
  });
1306
1309
  });
1307
1310
  }
@@ -2113,7 +2116,9 @@ export function unregisterServer(opts) {
2113
2116
  const codexPrevHash = audit ? hashFileContents(codexTarget) : '';
2114
2117
  let codex;
2115
2118
  try {
2116
- codex = unregisterCodexServer(opts);
2119
+ codex = opts.preserveCodex
2120
+ ? { skipped: true, reason: 'Codex hq endpoint remains owned by the global install' }
2121
+ : unregisterCodexServer(opts);
2117
2122
  }
2118
2123
  catch (e) {
2119
2124
  if (audit) {
@@ -2242,6 +2247,7 @@ export function unregisterMcpServers(pkg, names, options) {
2242
2247
  env: options?.env,
2243
2248
  lock: options?.lock,
2244
2249
  stamp: options?.stamp,
2250
+ preserveCodex: options?.preserveCodex,
2245
2251
  });
2246
2252
  });
2247
2253
  }
@@ -23,8 +23,9 @@
23
23
  */
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
+ import { isExecutableOnPath } from '../utils/resolve-executable.js';
26
27
  import { anywhereRuntimeEnabled } from './anywhere-runtime-flag.js';
27
- import { claudeTargets } from '../lib/install/claude-targets.js';
28
+ import { claudeTargets, MCP_SUBPROCESS_PATH } from '../lib/install/claude-targets.js';
28
29
  import { HQ_PACK_PROVENANCE_KEY, SECRET_REDACTION, backupRoot, claudeConfigFormat, claudeConfigPath, codexConfigFormat, codexConfigPath, CODEX_MCP_SERVERS_KEY, ConfigParseError, isCodexInstalled, readConfigDoc, resolveEnv, } from './mcp-registration.js';
29
30
  // ---------------------------------------------------------------------------
30
31
  // Pure status computation.
@@ -40,7 +41,11 @@ function provenanceOf(def) {
40
41
  }
41
42
  /** The transport string off a def (`type`), or '' when not a string. */
42
43
  function transportOf(def) {
43
- return typeof def.type === 'string' ? def.type : '';
44
+ if (typeof def.type === 'string')
45
+ return def.type;
46
+ // Claude Code's plugin .mcp.json omits `type` for command-backed servers;
47
+ // both Claude and Codex interpret that shape as stdio.
48
+ return typeof def.command === 'string' ? 'stdio' : '';
44
49
  }
45
50
  /**
46
51
  * The target off a def — url (http/sse) or command(+args) (stdio). NEVER a
@@ -124,9 +129,15 @@ function findAnywherePluginMcp(home) {
124
129
  function pluginAndCodexMcpEquivalent(plugin, codex) {
125
130
  const pArgs = Array.isArray(plugin.args) ? plugin.args : [];
126
131
  const cArgs = Array.isArray(codex.args) ? codex.args : [];
127
- const pluginTarget = plugin.type === 'stdio' && plugin.command === '/bin/sh' && pArgs.length === 3 && pArgs[0] === '${CLAUDE_PLUGIN_ROOT}/scripts/hq-claude-plugin-launch.sh' && pArgs[1] === 'mcp' && pArgs[2] === 'serve';
128
- const codexTarget = codex.type === 'stdio' && typeof codex.command === 'string' && /(^|\/)hq$/.test(codex.command) && cArgs.length === 2 && cArgs[0] === 'mcp' && cArgs[1] === 'serve';
129
- return pluginTarget && codexTarget && JSON.stringify(headerKeysOf(plugin)) === JSON.stringify(headerKeysOf(codex)) && JSON.stringify(envKeysOf(plugin)) === JSON.stringify(envKeysOf(codex));
132
+ const pluginTarget = transportOf(plugin) === 'stdio' && plugin.command === '/bin/sh' && pArgs.length === 3 && pArgs[0] === '${CLAUDE_PLUGIN_ROOT}/scripts/hq-claude-plugin-launch.sh' && pArgs[1] === 'mcp' && pArgs[2] === 'serve';
133
+ const bareOrAbsoluteHq = typeof codex.command === 'string' && /(^|[\\/])hq(?:\.exe)?$/.test(codex.command) && cArgs.length === 2 && cArgs[0] === 'mcp' && cArgs[1] === 'serve';
134
+ const globalNodeEntry = typeof codex.command === 'string' && path.isAbsolute(codex.command) && /(^|[\\/])node(?:\.exe)?$/.test(codex.command) && cArgs.length === 3 && typeof cArgs[0] === 'string' && path.isAbsolute(cArgs[0]) && cArgs[1] === 'mcp' && cArgs[2] === 'serve';
135
+ const codexTarget = transportOf(codex) === 'stdio' && (bareOrAbsoluteHq || globalNodeEntry);
136
+ const pluginEnv = plugin.env && typeof plugin.env === 'object' ? plugin.env : {};
137
+ const codexEnv = codex.env && typeof codex.env === 'object' ? codex.env : {};
138
+ const envMatch = JSON.stringify(envKeysOf(plugin)) === JSON.stringify(envKeysOf(codex)) ||
139
+ (globalNodeEntry && Object.keys(pluginEnv).length === 0 && Object.keys(codexEnv).length === 1 && codexEnv.PATH === MCP_SUBPROCESS_PATH);
140
+ return pluginTarget && codexTarget && JSON.stringify(headerKeysOf(plugin)) === JSON.stringify(headerKeysOf(codex)) && envMatch;
130
141
  }
131
142
  /** Scan the Claude runtime: read `~/.claude.json`, collect HQ-owned servers. */
132
143
  function scanClaude(env) {
@@ -208,12 +219,15 @@ function isLaunchable(def) {
208
219
  if (transportOf(def) !== 'stdio')
209
220
  return true;
210
221
  const command = def.command;
211
- if (typeof command !== 'string' || !path.isAbsolute(command) || !fs.existsSync(command)) {
222
+ if (typeof command !== 'string' || !commandIsLaunchable(command))
212
223
  return false;
213
- }
214
224
  const args = Array.isArray(def.args) ? def.args : [];
215
225
  return args.every((a) => typeof a !== 'string' || !path.isAbsolute(a) || fs.existsSync(a));
216
226
  }
227
+ /** Codex resolves bare MCP commands through the host process PATH. */
228
+ function commandIsLaunchable(command) {
229
+ return isExecutableOnPath(command);
230
+ }
217
231
  /** List the most-recent backup dir names under `backupRoot` (newest first, capped). */
218
232
  function recentBackups(env) {
219
233
  const root = backupRoot(env);
@@ -336,6 +336,24 @@ export declare function confirmMcp(pkg: PackManifest, payloadDir: string, allowM
336
336
  * authorization response. No cached value is a fallback.
337
337
  */
338
338
  export declare function makeInstallSecretResolver(secretNames: string[], company?: string): Promise<SecretResolver>;
339
+ /**
340
+ * Register one pack's `contributes.mcp` servers into the shared Claude/Codex
341
+ * agent configs, PER SERVER, so a single unresolvable `${secret:NAME}` defers
342
+ * ONLY that server instead of aborting the whole install. Returns the registered
343
+ * and skipped (deferred) server-name lists for the one-line summary.
344
+ *
345
+ * Per-server policy:
346
+ * - SUCCESS → push to `registered`.
347
+ * - McpManifestError matching
348
+ * `/cannot resolve \$\{secret:/` → SKIP (push to `skipped`), warn on stderr,
349
+ * install still succeeds (the key gets minted later, then a re-install wires it).
350
+ * - ANY OTHER error → RE-THROW (abort install). Only the
351
+ * unresolvable-secret case is swallowed.
352
+ */
353
+ export declare function wireMcpServers(pkg: PackManifest, destDir: string, company?: string): Promise<{
354
+ registered: string[];
355
+ skipped: string[];
356
+ }>;
339
357
  /**
340
358
  * Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
341
359
  * v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
@@ -46,13 +46,14 @@ import semverValid from 'semver/functions/valid.js';
46
46
  import semverValidRange from 'semver/ranges/valid.js';
47
47
  import semverGt from 'semver/functions/gt.js';
48
48
  import { findHqRoot } from '../utils/manifest.js';
49
- import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
49
+ import { codexTargets } from '../lib/install/codex-targets.js';
50
+ import { readHqVersion, routeContribution, listInstalledPacks, mcpServerNameForContribution } from '../utils/pack-contributions.js';
50
51
  import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
51
52
  import { safeExtractTarball } from './safe-extract.js';
52
53
  import { getCompanyUid, vaultApiFetch, vaultApiFetchPublic, } from '../utils/vault-api.js';
53
54
  import { ensureCognitoToken } from '../utils/cognito-session.js';
54
55
  import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
55
- import { redactSecrets, SECRET_REDACTION, isMcpRegistrationEnabled, registerMcpServers, McpManifestError, } from './mcp-registration.js';
56
+ import { redactSecrets, SECRET_REDACTION, codexConfigFormat, codexConfigPath, isMcpRegistrationEnabled, registerMcpServers, readConfigDoc, unregisterMcpServers, McpManifestError, } from './mcp-registration.js';
56
57
  import { listSecretCacheScopes } from '../utils/secrets-cache.js';
57
58
  import { loadRevealedSecrets } from './secrets.js';
58
59
  import { syncCodexPackHooks, syncCodexPackSkills } from '../lib/install/codex-pack-contributions.js';
@@ -1492,7 +1493,7 @@ function extractDeferredSecretName(message) {
1492
1493
  * - ANY OTHER error → RE-THROW (abort install). Only the
1493
1494
  * unresolvable-secret case is swallowed.
1494
1495
  */
1495
- async function wireMcpServers(pkg, destDir, company) {
1496
+ export async function wireMcpServers(pkg, destDir, company) {
1496
1497
  const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
1497
1498
  const registered = [];
1498
1499
  const skipped = [];
@@ -1501,14 +1502,29 @@ async function wireMcpServers(pkg, destDir, company) {
1501
1502
  const registrationEnabled = isMcpRegistrationEnabled();
1502
1503
  for (const name of pkg.contributes.mcp ?? []) {
1503
1504
  try {
1505
+ // The reserved hq-anywhere pack shares the global `hq` endpoint. Remove
1506
+ // the pre-pairing alias left by older installs before registering `hq`.
1507
+ const registrationName = mcpServerNameForContribution(pkg.name, name);
1508
+ const codexHome = process.env.HOME ?? os.homedir();
1509
+ const globalStatePresent = pkg.name === 'hq-anywhere' && fs.existsSync(codexTargets(codexHome).stateFile);
1510
+ const codexDoc = globalStatePresent ? readConfigDoc(codexConfigPath({ home: codexHome }), codexConfigFormat).doc : undefined;
1511
+ const codexServers = codexDoc?.value.mcp_servers;
1512
+ const globalCodexOwnsHq = globalStatePresent && codexServers?.hq?._hqPack === pkg.name;
1504
1513
  const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
1505
1514
  // Per-server call: registerMcpServers throws on the FIRST unresolvable
1506
1515
  // secret, so calling it one name at a time lets us catch + continue.
1507
- const results = registerMcpServers(pkg.name, [name], {
1508
- loadManifest,
1516
+ const results = registerMcpServers(pkg.name, [registrationName], {
1517
+ loadManifest: (registeredName) => loadManifest(registeredName === registrationName ? name : registeredName),
1509
1518
  resolveSecret,
1510
1519
  registrationEnabled,
1520
+ preserveCodex: globalCodexOwnsHq,
1511
1521
  });
1522
+ // Only retire the pre-pairing alias after the canonical registration has
1523
+ // been written to Codex. A kill switch, absent Codex runtime, or deferred
1524
+ // secret must leave the prior working entry untouched.
1525
+ if (registrationName !== name && results.some((result) => !('skipped' in result.codex) || (globalCodexOwnsHq && result.codex.reason === 'shared global install owns Codex hq endpoint'))) {
1526
+ unregisterMcpServers(pkg.name, [name]);
1527
+ }
1512
1528
  if (results.length > 0)
1513
1529
  registered.push(name);
1514
1530
  }
@@ -1763,7 +1779,7 @@ export async function installPack(source, opts = {}) {
1763
1779
  const codexHome = process.env.HOME ?? os.homedir();
1764
1780
  const packHooks = pkg.contributes.hooks ?? [];
1765
1781
  const skillWarnings = syncCodexPackSkills(destDir, pkg.name, pkg.contributes.skills ?? [], false, codexHome);
1766
- syncCodexPackHooks(destDir, packHooks, false, codexHome);
1782
+ syncCodexPackHooks(destDir, packHooks, false, codexHome, hqRoot, pkg.name);
1767
1783
  for (const warning of skillWarnings)
1768
1784
  say(chalk.yellow(` ! ${warning}`));
1769
1785
  // US-007 — register `contributes.mcp` servers into the shared Claude/Codex
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import * as fs from 'fs';
20
20
  import * as os from 'os';
21
+ import { codexTargets } from '../lib/install/codex-targets.js';
21
22
  import * as path from 'path';
22
23
  import * as readline from 'readline';
23
24
  import { spawnSync } from 'child_process';
@@ -252,7 +253,7 @@ async function runUpdate(name, opts) {
252
253
  // Remove Codex registrations and skills from the old manifest before the
253
254
  // pack directory is replaced. The replacement may rename its commands or
254
255
  // drop contributions entirely, so the new manifest cannot clean those.
255
- syncCodexPackHooks(pack.dir, m.contributes?.hooks ?? [], true);
256
+ syncCodexPackHooks(pack.dir, m.contributes?.hooks ?? [], true, process.env.HOME ?? os.homedir(), hqRoot, pname);
256
257
  syncCodexPackSkills(pack.dir, pname, m.contributes?.skills ?? [], true);
257
258
  // Un-wire old contributions first so a contribution dropped by the new
258
259
  // version doesn't leave a dangling host symlink. installPack re-wires.
@@ -314,7 +315,7 @@ async function runUninstall(name, opts) {
314
315
  // marker/absolute command still points at this pack.
315
316
  try {
316
317
  const home = process.env.HOME ?? os.homedir();
317
- syncCodexPackHooks(packDir, contributes.hooks ?? [], true, home);
318
+ syncCodexPackHooks(packDir, contributes.hooks ?? [], true, home, hqRoot, name);
318
319
  warnings.push(...syncCodexPackSkills(packDir, name, contributes.skills ?? [], true, home));
319
320
  }
320
321
  catch (e) {
@@ -325,7 +326,12 @@ async function runUninstall(name, opts) {
325
326
  // pack's `_hqPack`, and skip-and-warns on any foreign/unstamped same-named entry.
326
327
  // Tolerant of a Codex-less host / absent config; idempotent on re-run.
327
328
  try {
328
- const mcp = unwirePackMcp(name, contributes);
329
+ const home = process.env.HOME ?? os.homedir();
330
+ const globalCodexState = codexTargets(home).stateFile;
331
+ const mcp = unwirePackMcp(name, contributes, {
332
+ env: { home },
333
+ preserveCodex: name === 'hq-anywhere' && fs.existsSync(globalCodexState),
334
+ });
329
335
  for (const server of mcp.servers) {
330
336
  if ('skipped' in server.claude)
331
337
  continue; // (claude is always inspected; never skipped)
@@ -0,0 +1,2 @@
1
+ export declare function awkCannotOpenFileDiagnostic(filename: string, reason: string, sourceLine?: number, platform?: NodeJS.Platform, pathValue?: string | undefined): string;
2
+ //# sourceMappingURL=awk-diagnostics.d.ts.map
@@ -0,0 +1,39 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * The bundled shell companions emit awk's open-file diagnostic. GNU awk and
5
+ * the BSD awk shipped by macOS format that diagnostic differently. Prefer
6
+ * the executable selected by PATH; macOS runners can put GNU awk ahead of the
7
+ * system awk.
8
+ */
9
+ function usesGnuAwk(pathValue, platform) {
10
+ for (const directory of (pathValue ?? "/usr/bin:/bin").split(path.delimiter)) {
11
+ if (!directory)
12
+ continue;
13
+ const candidate = path.join(directory, "awk");
14
+ try {
15
+ if (!fs.statSync(candidate).isFile())
16
+ continue;
17
+ fs.accessSync(candidate, fs.constants.X_OK);
18
+ const resolved = fs.realpathSync(candidate);
19
+ if (/^gawk(?:[-.]|$)/i.test(path.basename(resolved)))
20
+ return true;
21
+ if (resolved.split(path.sep).some((segment) => /^gawk(?:[-.]|$)/i.test(segment)))
22
+ return true;
23
+ // Some runners expose GNU awk under the generic name `awk`.
24
+ if (fs.readFileSync(resolved).includes(Buffer.from("GNU Awk")))
25
+ return true;
26
+ return platform !== "darwin";
27
+ }
28
+ catch {
29
+ // execvp continues through PATH entries that lack an executable awk.
30
+ }
31
+ }
32
+ return platform !== "darwin";
33
+ }
34
+ export function awkCannotOpenFileDiagnostic(filename, reason, sourceLine = 1, platform = process.platform, pathValue = process.env.PATH) {
35
+ if (!usesGnuAwk(pathValue, platform))
36
+ return `awk: can't open file ${filename}\n source line number ${sourceLine}\n`;
37
+ return `awk: fatal: cannot open file \`${filename}' for reading: ${reason}\n`;
38
+ }
39
+ //# sourceMappingURL=awk-diagnostics.js.map
@@ -0,0 +1,5 @@
1
+ type LocaleEnvironment = Partial<Pick<NodeJS.ProcessEnv, "LC_ALL" | "LC_CTYPE" | "LANG">>;
2
+ /** Whether the effective locale makes the shell's character classes byte-oriented. */
3
+ export declare function isByteOrientedLocale(env: LocaleEnvironment, platform?: NodeJS.Platform, localeDirectory?: string): boolean;
4
+ export {};
5
+ //# sourceMappingURL=byte-oriented-locale.d.ts.map
@@ -0,0 +1,23 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ function macLocaleIsRecognized(locale, localeDirectory) {
4
+ // Locale names accepted by macOS are backed by LC_CTYPE data in this directory.
5
+ // Keep the complete libc locale name intact; Intl's locale database is unrelated.
6
+ if (locale.includes("/"))
7
+ return false;
8
+ return fs.existsSync(path.join(localeDirectory, locale, "LC_CTYPE"));
9
+ }
10
+ /** Whether the effective locale makes the shell's character classes byte-oriented. */
11
+ export function isByteOrientedLocale(env, platform = process.platform, localeDirectory = "/usr/share/locale") {
12
+ const locale = env.LC_ALL || env.LC_CTYPE || env.LANG;
13
+ if (!locale)
14
+ return true;
15
+ if (/^(?:C|POSIX)$/i.test(locale) || /^C@/i.test(locale))
16
+ return true;
17
+ if (platform === "darwin")
18
+ return !macLocaleIsRecognized(locale, localeDirectory);
19
+ if (/^(?:C|POSIX)\./i.test(locale))
20
+ return !/^\w+\.utf-?8$/i.test(locale);
21
+ return false;
22
+ }
23
+ //# sourceMappingURL=byte-oriented-locale.js.map
@@ -2,7 +2,7 @@ type Doc = Record<string, unknown>;
2
2
  export declare function syncCodexPackSkills(packDir: string, packName: string, names: string[], remove?: boolean, home?: string): string[];
3
3
  export declare function mergeCodexPackHooks(doc: Doc, packDir: string, items: string[]): Doc;
4
4
  export declare function removeCodexPackHooks(doc: Doc, packDir: string, items: string[]): Doc;
5
- export declare function syncCodexPackHooks(packDir: string, items: string[], remove?: boolean, home?: string): void;
5
+ export declare function syncCodexPackHooks(packDir: string, items: string[], remove?: boolean, home?: string, hqRoot?: string, packName?: string): void;
6
6
  export declare function readCodexPackHooks(target: string): Doc;
7
7
  export {};
8
8
  //# sourceMappingURL=codex-pack-contributions.d.ts.map
@@ -3,6 +3,8 @@ import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { anywhereRuntimeEnabled } from '../../commands/anywhere-runtime-flag.js';
5
5
  import { jsonFormat, readConfigDoc, writeConfigAtomic } from '../../commands/mcp-registration.js';
6
+ import { hookTargetFor } from '../../commands/install-global.js';
7
+ import { addCodexHooks, codexTargets, removeCodexHooks } from './codex-targets.js';
6
8
  const configFormat = {
7
9
  parse: jsonFormat.parse,
8
10
  serialize: jsonFormat.serialize,
@@ -186,14 +188,35 @@ function removeCommands(hooks, commands) {
186
188
  delete hooks[event];
187
189
  }
188
190
  }
189
- export function syncCodexPackHooks(packDir, items, remove = false, home = os.homedir()) {
191
+ export function syncCodexPackHooks(packDir, items, remove = false, home = os.homedir(), hqRoot, packName = 'hq-anywhere') {
190
192
  assertContributionNames(items);
191
193
  if ((!remove && !anywhereRuntimeEnabled()) || !fs.existsSync(path.join(home, '.codex')))
192
194
  return;
193
195
  const codexItems = items.filter((item) => fs.existsSync(path.join(packDir, 'hooks', `${item}-hooks.json`)));
194
196
  if (codexItems.length === 0)
195
197
  return;
198
+ const sharedAnywherePack = packName === 'hq-anywhere';
199
+ const sharedGlobalInstall = sharedAnywherePack && fs.existsSync(codexTargets(home).stateFile);
200
+ // The global Codex installer owns the canonical command while its state is
201
+ // present. The pack still contributes skills/MCP, but must not register a
202
+ // second hook or remove the shared hook during pack uninstall/update.
203
+ if (sharedGlobalInstall)
204
+ return;
196
205
  const target = hooksPath(home);
206
+ if (hqRoot && sharedAnywherePack) {
207
+ const hookTarget = hookTargetFor(hqRoot, home);
208
+ writeConfigAtomic({
209
+ target,
210
+ pack: 'hq-anywhere',
211
+ format: configFormat,
212
+ merge: (doc) => remove
213
+ ? removeCodexHooks(removeCodexPackHooks(doc, packDir, codexItems), hqRoot)
214
+ : addCodexHooks(removeCodexPackHooks(doc, packDir, codexItems), hqRoot, hookTarget),
215
+ assertName: remove ? `hooks-remove:${path.resolve(hqRoot)}${path.sep}` : codexItems[0],
216
+ env: { home },
217
+ });
218
+ return;
219
+ }
197
220
  writeConfigAtomic({
198
221
  target, pack: 'hq-anywhere', format: configFormat,
199
222
  merge: (doc) => remove ? removeCodexPackHooks(doc, packDir, codexItems) : mergeCodexPackHooks(doc, packDir, codexItems),
@@ -93,7 +93,9 @@ function isAdapterEntry(entry, hqRoot) {
93
93
  return false;
94
94
  const cmd = entry.command;
95
95
  return (typeof cmd === 'string' &&
96
- (cmd.includes(JSON.stringify(codexAdapterPath(hqRoot))) || cmd.includes(JSON.stringify(shimPath(hqRoot)))));
96
+ (cmd.includes(JSON.stringify(codexAdapterPath(hqRoot))) ||
97
+ cmd.includes(JSON.stringify(shimPath(hqRoot))) ||
98
+ cmd.includes(JSON.stringify(path.join(hqRoot, 'core', 'packages', 'hq-anywhere', 'hooks', 'codex.sh')))));
97
99
  }
98
100
  /**
99
101
  * Add one HQ hook group per event; idempotent. An HQ entry for the other
@@ -54,6 +54,8 @@ export type { WireMode };
54
54
  * rather than relying on `linkFor` happening to return `null`.
55
55
  */
56
56
  export declare function routeContribution(key: PackContributeKey): WireMode;
57
+ /** Keep the reserved hq-anywhere pack on the same user-visible MCP name as global HQ. */
58
+ export declare function mcpServerNameForContribution(packName: string, contributionName: string): string;
57
59
  /**
58
60
  * The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
59
61
  * (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
@@ -142,6 +144,8 @@ export interface UnwirePackMcpOptions {
142
144
  lock?: AcquireLockOptions;
143
145
  /** Backup timestamp override (deterministic tests). */
144
146
  stamp?: string;
147
+ /** Keep the shared Codex `hq` endpoint when global Codex install state remains. */
148
+ preserveCodex?: boolean;
145
149
  }
146
150
  /**
147
151
  * Un-register the `wire: 'merge'` (MCP) servers a pack declares — the parallel to
@@ -60,6 +60,10 @@ export { CONTRIBUTION_TABLE };
60
60
  export function routeContribution(key) {
61
61
  return CONTRIBUTION_TABLE[key].wire;
62
62
  }
63
+ /** Keep the reserved hq-anywhere pack on the same user-visible MCP name as global HQ. */
64
+ export function mcpServerNameForContribution(packName, contributionName) {
65
+ return packName === 'hq-anywhere' && contributionName === 'hq-anywhere' ? 'hq' : contributionName;
66
+ }
63
67
  /**
64
68
  * The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
65
69
  * (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
@@ -333,14 +337,26 @@ export function unwirePack(hqRoot, packDir, contributes) {
333
337
  export function unwirePackMcp(packName, contributes, options) {
334
338
  const servers = [];
335
339
  for (const key of mergeKeys(contributes)) {
336
- const names = (contributes[key] ?? []).filter((n) => typeof n === 'string' && n.length > 0);
337
- if (names.length === 0)
340
+ const declaredNames = (contributes[key] ?? []).filter((n) => typeof n === 'string' && n.length > 0);
341
+ if (declaredNames.length === 0)
338
342
  continue;
339
- servers.push(...unregisterMcpServers(packName, names, {
340
- env: options?.env,
341
- lock: options?.lock,
342
- stamp: options?.stamp,
343
- }));
343
+ for (const declaredName of declaredNames) {
344
+ const registeredName = mcpServerNameForContribution(packName, declaredName);
345
+ if (registeredName !== declaredName) {
346
+ // Remove the prior pack-only alias from older installs during upgrade/uninstall.
347
+ servers.push(...unregisterMcpServers(packName, [declaredName], {
348
+ env: options?.env,
349
+ lock: options?.lock,
350
+ stamp: options?.stamp,
351
+ }));
352
+ }
353
+ servers.push(...unregisterMcpServers(packName, [registeredName], {
354
+ env: options?.env,
355
+ lock: options?.lock,
356
+ stamp: options?.stamp,
357
+ preserveCodex: options?.preserveCodex && packName === 'hq-anywhere' && registeredName === 'hq',
358
+ }));
359
+ }
344
360
  }
345
361
  return { servers };
346
362
  }
@@ -0,0 +1,3 @@
1
+ export declare function executableCandidates(command: string, pathValue?: string, platform?: NodeJS.Platform, pathExt?: string): string[];
2
+ export declare function isExecutableOnPath(command: string, pathValue?: string, platform?: NodeJS.Platform, pathExt?: string): boolean;
3
+ //# sourceMappingURL=resolve-executable.d.ts.map
@@ -0,0 +1,23 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ export function executableCandidates(command, pathValue = process.env.PATH ?? '', platform = process.platform, pathExt = process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM') {
4
+ const extensions = platform === 'win32'
5
+ ? (pathExt || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
6
+ : [''];
7
+ const candidates = path.isAbsolute(command)
8
+ ? [command]
9
+ : pathValue.split(platform === 'win32' ? ';' : path.delimiter).filter(Boolean).flatMap((directory) => extensions.map((extension) => path.join(directory, `${command}${extension}`)));
10
+ return candidates;
11
+ }
12
+ export function isExecutableOnPath(command, pathValue = process.env.PATH ?? '', platform = process.platform, pathExt = process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM') {
13
+ return executableCandidates(command, pathValue, platform, pathExt).some((candidate) => {
14
+ try {
15
+ fs.accessSync(candidate, fs.constants.X_OK);
16
+ return fs.statSync(candidate).isFile();
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ });
22
+ }
23
+ //# sourceMappingURL=resolve-executable.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.331.0",
3
+ "version": "5.332.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {