@phnx-labs/agents-cli 1.20.46 → 1.20.48

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/dist/lib/shims.js CHANGED
@@ -1610,6 +1610,85 @@ export function isShimCurrent(agent) {
1610
1610
  const version = readShimSchemaVersion(agent);
1611
1611
  return version === SHIM_SCHEMA_VERSION;
1612
1612
  }
1613
+ /** Extract the baked `AGENTS_BIN='...'` value from a shim file, or null. */
1614
+ function readAgentsBinFromShim(shimPath) {
1615
+ try {
1616
+ const header = fs.readFileSync(shimPath, 'utf8').split('\n', 12).join('\n');
1617
+ const m = header.match(/^AGENTS_BIN=(?:'([^']*)'|"([^"]*)"|(\S+))/m);
1618
+ return m ? (m[1] ?? m[2] ?? m[3] ?? null) : null;
1619
+ }
1620
+ catch {
1621
+ return null;
1622
+ }
1623
+ }
1624
+ /**
1625
+ * True when the agent shim's baked `AGENTS_BIN` is fine to keep: it either already
1626
+ * points at the install we'd generate now, OR points at some OTHER install that
1627
+ * still exists on disk (leave it — regenerating could ping-pong two live installs
1628
+ * sharing the shims dir). Returns FALSE only when the shim points at a DIFFERENT,
1629
+ * now-removed install — the exact drift a deleted dev build (`~/.local/agents-cli-dev`),
1630
+ * an old npm-global (`/opt/homebrew`), or a rotated version dir leaves behind. A shim
1631
+ * can pass the schema check (`isShimCurrent`) yet still carry that stale path.
1632
+ */
1633
+ export function shimPointsAtLiveInstall(agent) {
1634
+ if (!shimExists(agent))
1635
+ return true; // missing shim is handled by ensureShimCurrent
1636
+ const baked = readAgentsBinFromShim(onDiskShimPath(agent));
1637
+ if (!baked)
1638
+ return true;
1639
+ if (baked === getAgentsBinForGeneratedShim())
1640
+ return true; // already the current install
1641
+ return fs.existsSync(baked); // a different install — keep only while it still exists
1642
+ }
1643
+ /** Shim files in the shims dir, excluding the hooks/ subdir and @-versioned aliases. */
1644
+ export function listShimFileNames() {
1645
+ try {
1646
+ return fs
1647
+ .readdirSync(getShimsDir(), { withFileTypes: true })
1648
+ .filter((e) => e.isFile() && !e.name.includes('@'))
1649
+ .map((e) => e.name);
1650
+ }
1651
+ catch {
1652
+ return [];
1653
+ }
1654
+ }
1655
+ /**
1656
+ * Prune a stale, orphaned shim: one that is NOT a managed agent shim and NOT a user
1657
+ * alias, whose baked `AGENTS_BIN` points at an install that no longer exists. These
1658
+ * are legacy `exec "$AGENTS_BIN" <cmd>` command shims (browser/secrets/sessions/…)
1659
+ * left behind by a removed install — the current source never generates them, and
1660
+ * they either die with `exit 127` or shadow the real package bin on PATH. Only
1661
+ * removed when the baked target is gone, so a working shim is never touched.
1662
+ * Returns true if removed.
1663
+ */
1664
+ export function pruneOrphanedCommandShim(fileName) {
1665
+ // Never touch a shim that corresponds to a real agent — agents-cli manages those.
1666
+ const isAgentCommand = Object.values(AGENTS).some((a) => a.cliCommand === fileName);
1667
+ if (isAgentCommand)
1668
+ return false;
1669
+ const shimPath = path.join(getShimsDir(), fileName);
1670
+ let content;
1671
+ try {
1672
+ content = fs.readFileSync(shimPath, 'utf8');
1673
+ }
1674
+ catch {
1675
+ return false;
1676
+ }
1677
+ if (content.includes('# Alias shim:'))
1678
+ return false; // a user `agents alias` — leave it
1679
+ const bin = readAgentsBinFromShim(shimPath);
1680
+ if (!bin)
1681
+ return false; // not an AGENTS_BIN-baked shim
1682
+ if (fs.existsSync(bin))
1683
+ return false; // its install is still alive — leave it
1684
+ try {
1685
+ fs.rmSync(shimPath);
1686
+ return true;
1687
+ }
1688
+ catch {
1689
+ return false;
1690
+ }
1691
+ }
1613
1692
  /**
1614
1693
  * Regenerate the shim if it's missing or outdated. Returns a status describing
1615
1694
  * what happened — callers can surface a one-line notice to the user ("Updated
@@ -320,6 +320,21 @@ export declare function isMissingBinarySignature(output: string): boolean;
320
320
  * missing-binary signature (see isMissingBinarySignature) fails the check; a
321
321
  * plain nonzero exit or a timeout is treated as healthy so we never false-fail.
322
322
  */
323
+ /**
324
+ * Compose the spawn spec for a `<binary> --version` launch probe. On Windows the
325
+ * `.cmd` wrapper runs through cmd.exe, so the path is fully quoted into ONE
326
+ * command line and the args array is emptied (composeWin32CommandLine) — the
327
+ * DEP0190-safe pattern the real launch uses. Critically this keeps a spaced
328
+ * Windows profile path (`C:\Users\John Doe\…\claude.cmd`) intact; passing the raw
329
+ * path to a shell would split it at the space and false-fail a HEALTHY install.
330
+ * On POSIX no shell is involved and the binary is exec'd directly. Pure/exported
331
+ * so the quoting is unit-testable without spawning.
332
+ */
333
+ export declare function probeSpawnSpec(binary: string, isWin: boolean): {
334
+ command: string;
335
+ args: string[];
336
+ shell: boolean;
337
+ };
323
338
  export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: string): Promise<{
324
339
  ok: boolean;
325
340
  detail?: string;
@@ -344,6 +359,7 @@ export declare function verifyInstalledBinaryLaunches(agent: AgentId, version: s
344
359
  * droid) have no such tarball and are returned unchanged.
345
360
  */
346
361
  export declare function ensureAgentRunnable(agent: AgentId, version: string, log?: (message: string) => void): Promise<string | null>;
362
+ export declare function healBrokenDefaultLaunches(log?: (m: string) => void): Promise<string[]>;
347
363
  /** Outcome of syncing resources to a version home, keyed by resource type. */
348
364
  export interface SyncResult {
349
365
  commands: boolean;
@@ -33,7 +33,7 @@ import { discoverPermissionGroups, getActivePermissionPresetName, readPermission
33
33
  import { parseMcpServerConfig } from './mcp.js';
34
34
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
35
35
  import { importInstallScriptBinary } from './import.js';
36
- import { IS_WINDOWS } from './platform/index.js';
36
+ import { IS_WINDOWS, composeWin32CommandLine } from './platform/index.js';
37
37
  import { pruneVersionHomeHookEntriesFromSettings } from './hooks.js';
38
38
  import { supports, explainSkip } from './capabilities.js';
39
39
  import { discoverPlugins } from './plugins.js';
@@ -1621,22 +1621,48 @@ export function isMissingBinarySignature(output) {
1621
1621
  * missing-binary signature (see isMissingBinarySignature) fails the check; a
1622
1622
  * plain nonzero exit or a timeout is treated as healthy so we never false-fail.
1623
1623
  */
1624
+ /**
1625
+ * Compose the spawn spec for a `<binary> --version` launch probe. On Windows the
1626
+ * `.cmd` wrapper runs through cmd.exe, so the path is fully quoted into ONE
1627
+ * command line and the args array is emptied (composeWin32CommandLine) — the
1628
+ * DEP0190-safe pattern the real launch uses. Critically this keeps a spaced
1629
+ * Windows profile path (`C:\Users\John Doe\…\claude.cmd`) intact; passing the raw
1630
+ * path to a shell would split it at the space and false-fail a HEALTHY install.
1631
+ * On POSIX no shell is involved and the binary is exec'd directly. Pure/exported
1632
+ * so the quoting is unit-testable without spawning.
1633
+ */
1634
+ export function probeSpawnSpec(binary, isWin) {
1635
+ if (isWin)
1636
+ return { command: composeWin32CommandLine(binary, ['--version']), args: [], shell: true };
1637
+ return { command: binary, args: ['--version'], shell: false };
1638
+ }
1624
1639
  export async function verifyInstalledBinaryLaunches(agent, version) {
1625
- // Windows: `getBinaryPath` returns the extensionless `.bin/<cli>` (a shell
1626
- // wrapper), NOT the `.cmd`/`.exe` that actually launches there — `execFile`ing
1627
- // it would ENOENT on a perfectly healthy install, and the integrity gate would
1628
- // then WIPE it. The gutted-native-binary failure this guards against is a POSIX
1629
- // concern in practice; treat win32 as healthy rather than risk destroying a
1630
- // good install. (isVersionInstalled already validates presence on Windows.)
1631
- if (process.platform === 'win32')
1632
- return { ok: true };
1633
- const binary = getBinaryPath(agent, version);
1640
+ // The real launch target differs by platform, so probe whatever `agents run`
1641
+ // actually execs. On Windows that's the npm `.cmd` wrapper (exec.ts uses
1642
+ // `absPath + '.cmd'`), which chains to the native `.exe`; a gutted install
1643
+ // (renamed/missing `.exe`) makes that wrapper emit "is not recognized" the
1644
+ // exact win-mini failure a vendor auto-update leaves behind. Probing the
1645
+ // extensionless `.bin/<cli>` instead would ENOENT even on a HEALTHY Windows
1646
+ // install, so we DON'T. On POSIX the `.bin/<cli>` binary is the launch target
1647
+ // and is probed directly.
1648
+ const isWin = process.platform === 'win32';
1649
+ const binary = isWin ? getBinaryPath(agent, version) + '.cmd' : getBinaryPath(agent, version);
1634
1650
  if (!fs.existsSync(binary)) {
1635
- return { ok: false, detail: `binary not found at ${binary}` };
1651
+ // Windows: a missing `.cmd` means a non-npm/global agent (droid.exe) we can't
1652
+ // safely probe — treat as healthy (isVersionInstalled validates presence).
1653
+ // POSIX: a missing launch binary is a genuine gutted install.
1654
+ return isWin ? { ok: true } : { ok: false, detail: `binary not found at ${binary}` };
1636
1655
  }
1637
1656
  try {
1638
- await execFileAsync(binary, ['--version'], {
1657
+ // On Windows the `.cmd` runs via cmd.exe (shell). Pass a single FULLY-QUOTED
1658
+ // command line + EMPTY args (composeWin32CommandLine) — the same DEP0190-safe
1659
+ // pattern the real launch uses (exec.ts) — so a space in the Windows profile
1660
+ // path (`C:\Users\John Doe\…`) can't split the path and false-fail a healthy
1661
+ // install into a destructive reinstall.
1662
+ const spec = probeSpawnSpec(binary, isWin);
1663
+ await execFileAsync(spec.command, spec.args, {
1639
1664
  timeout: 15000,
1665
+ shell: spec.shell,
1640
1666
  env: { ...process.env, HOME: getVersionHomePath(agent, version) },
1641
1667
  });
1642
1668
  return { ok: true };
@@ -1703,6 +1729,51 @@ export async function ensureAgentRunnable(agent, version, log) {
1703
1729
  }
1704
1730
  return null;
1705
1731
  }
1732
+ /**
1733
+ * Proactive launch-health pass for the daemon. Probe the DEFAULT version of
1734
+ * every npm-package agent and repair any that won't launch (via
1735
+ * ensureAgentRunnable), so a gutted install is healed BEFORE the user's next
1736
+ * `agents run` hits a raw ENOENT — the run-time heal (ensureAgentRunnable) only
1737
+ * fires once a run is already starting; this catches it in the background.
1738
+ *
1739
+ * Returns a label (`agent@broken→healed`) for each version actually repaired, so
1740
+ * the daemon can log/notify. A version that already launches costs one cheap
1741
+ * `--version` probe and is left untouched.
1742
+ */
1743
+ const failedRepairAt = new Map();
1744
+ const REPAIR_COOLDOWN_MS = 24 * 60 * 60_000;
1745
+ export async function healBrokenDefaultLaunches(log) {
1746
+ const repaired = [];
1747
+ for (const agent of Object.keys(AGENTS)) {
1748
+ if (!AGENTS[agent].npmPackage)
1749
+ continue; // native/global agents have no gutted-tarball failure mode
1750
+ const version = getGlobalDefault(agent);
1751
+ if (!version)
1752
+ continue;
1753
+ if ((await verifyInstalledBinaryLaunches(agent, version)).ok)
1754
+ continue;
1755
+ // Backoff: a version whose repair just failed (offline, npm 404, an arch the
1756
+ // registry can't serve) must NOT re-trigger a full clean-reinstall +
1757
+ // install-latest on every 6h pass. Skip it for a day; a daemon restart clears
1758
+ // the memo, giving a fresh attempt.
1759
+ const key = `${agent}@${version}`;
1760
+ const last = failedRepairAt.get(key);
1761
+ if (last !== undefined && Date.now() - last < REPAIR_COOLDOWN_MS) {
1762
+ log?.(`${AGENTS[agent].name}@${version} still won't launch — repair attempted recently, skipping until cooldown elapses.`);
1763
+ continue;
1764
+ }
1765
+ log?.(`${AGENTS[agent].name}@${version} won't launch — repairing…`);
1766
+ const healed = await ensureAgentRunnable(agent, version, log);
1767
+ if (healed) {
1768
+ failedRepairAt.delete(key);
1769
+ repaired.push(`${agent}@${version}${healed === version ? '' : `→${healed}`}`);
1770
+ }
1771
+ else {
1772
+ failedRepairAt.set(key, Date.now());
1773
+ }
1774
+ }
1775
+ return repaired;
1776
+ }
1706
1777
  async function getCliVersionFromPath(agent) {
1707
1778
  const agentConfig = AGENTS[agent];
1708
1779
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.46",
3
+ "version": "1.20.48",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,10 +44,10 @@
44
44
  "url": "https://github.com/phnx-labs/agents-cli/issues"
45
45
  },
46
46
  "scripts": {
47
- "build": "tsc && rm -rf 'dist/lib/secrets/AgentsKeychain.app' 'dist/lib/secrets/Agents CLI.app' 'dist/lib/menubar/MenubarHelper.app' && ([ \"$(uname)\" = \"Darwin\" ] && cp -R 'bin/Agents CLI.app' 'dist/lib/secrets/Agents CLI.app' || true) && ([ \"$(uname)\" = \"Darwin\" ] && [ -d 'bin/MenubarHelper.app' ] && mkdir -p 'dist/lib/menubar' && cp -R 'bin/MenubarHelper.app' 'dist/lib/menubar/MenubarHelper.app' || true)",
47
+ "build": "tsc && rm -rf 'dist/lib/secrets/AgentsKeychain.app' 'dist/lib/secrets/Agents CLI.app' 'dist/lib/menubar/MenubarHelper.app' && ([ -d 'bin/Agents CLI.app' ] && cp -R 'bin/Agents CLI.app' 'dist/lib/secrets/Agents CLI.app' || true) && ([ -d 'bin/MenubarHelper.app' ] && mkdir -p 'dist/lib/menubar' && cp -R 'bin/MenubarHelper.app' 'dist/lib/menubar/MenubarHelper.app' || true)",
48
48
  "build:bin": "scripts/build-bin.sh",
49
49
  "prepare": "npm run build",
50
- "prepack": "scripts/verify-keychain-helper.sh && scripts/verify-menubar-helper.sh",
50
+ "prepack": "cp ../../README.md README.md && scripts/verify-keychain-helper.sh && scripts/verify-menubar-helper.sh",
51
51
  "postinstall": "node scripts/postinstall.js",
52
52
  "dev": "tsx src/index.ts",
53
53
  "start": "node dist/index.js",