@phnx-labs/agents-cli 1.20.6 → 1.20.7

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Path classification + normalization, platform-aware.
3
+ */
4
+ import * as os from 'os';
5
+ /** Windows drive-letter absolute path: `C:\` or `C:/`. */
6
+ const WIN_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
7
+ /**
8
+ * Does this positional argument look like a filesystem path (vs a search term)?
9
+ *
10
+ * POSIX markers (`.`, `./`, `../`, `/`, `~`) are recognized on every platform —
11
+ * identical to the long-standing behavior. Windows-only shapes (drive-letter
12
+ * `C:\…`, UNC `\\…`, backslash-relative `.\` / `..\`) are recognized ONLY on
13
+ * win32, so a literal `C:\repo` typed on macOS/Linux still resolves as a search
14
+ * term — i.e. no behavior change off Windows.
15
+ */
16
+ export function looksLikePath(query, platform = process.platform) {
17
+ if (query === '.' ||
18
+ query.startsWith('./') ||
19
+ query.startsWith('../') ||
20
+ query.startsWith('/') ||
21
+ query.startsWith('~')) {
22
+ return true;
23
+ }
24
+ if (platform === 'win32') {
25
+ return (WIN_DRIVE_RE.test(query) ||
26
+ query.startsWith('\\\\') ||
27
+ query.startsWith('.\\') ||
28
+ query.startsWith('..\\'));
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Normalize a path for comparison/prefix-matching: backslashes folded to forward
34
+ * slashes and lowercased on Windows (its filesystem is case-insensitive). On
35
+ * POSIX the input is returned unchanged, so callers behave exactly as before.
36
+ */
37
+ export function toComparablePath(p, platform = process.platform) {
38
+ if (platform === 'win32')
39
+ return p.replace(/\\/g, '/').toLowerCase();
40
+ return p;
41
+ }
42
+ /**
43
+ * Canonical home directory. Use this instead of `process.env.HOME`, which is
44
+ * unset on Windows (where the home is `USERPROFILE`); `os.homedir()` resolves
45
+ * correctly on all three platforms.
46
+ */
47
+ export function homeDir() {
48
+ return os.homedir();
49
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Process liveness / control, platform-aware.
3
+ */
4
+ /**
5
+ * Is a process with this PID currently alive?
6
+ *
7
+ * Uses the signal-0 probe, which is cross-platform in Node (Windows included —
8
+ * it maps to OpenProcess). Returns false on any error (no such process, or no
9
+ * permission to signal it), matching the long-standing call sites that treat a
10
+ * throw from `process.kill(pid, 0)` as "not running".
11
+ */
12
+ export declare function isAlive(pid: number): boolean;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Process liveness / control, platform-aware.
3
+ */
4
+ /**
5
+ * Is a process with this PID currently alive?
6
+ *
7
+ * Uses the signal-0 probe, which is cross-platform in Node (Windows included —
8
+ * it maps to OpenProcess). Returns false on any error (no such process, or no
9
+ * permission to signal it), matching the long-standing call sites that treat a
10
+ * throw from `process.kill(pid, 0)` as "not running".
11
+ */
12
+ export function isAlive(pid) {
13
+ if (!pid || pid <= 0)
14
+ return false;
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
@@ -12,6 +12,7 @@ import * as path from 'path';
12
12
  import { getSocketPath, getPtyLogPath, isPtyServerRunning } from './pty-server.js';
13
13
  const CONNECT_TIMEOUT_MS = 5000;
14
14
  const RESPONSE_TIMEOUT_MS = 30000;
15
+ const IS_WINDOWS = process.platform === 'win32';
15
16
  /**
16
17
  * Send a request to the PTY server and return the response.
17
18
  * Auto-starts the server if not running.
@@ -41,11 +42,13 @@ async function ensureServer() {
41
42
  });
42
43
  child.unref();
43
44
  fs.closeSync(logFd);
44
- // Wait for socket to appear
45
+ // Wait for the server to become reachable. On Unix the socket file appearing is
46
+ // a cheap readiness signal; on Windows the named pipe is not a filesystem object
47
+ // (fs.existsSync always returns false), so we just attempt the ping directly.
45
48
  const socketPath = getSocketPath();
46
49
  const deadline = Date.now() + 5000;
47
50
  while (Date.now() < deadline) {
48
- if (fs.existsSync(socketPath)) {
51
+ if (IS_WINDOWS || fs.existsSync(socketPath)) {
49
52
  // Verify we can connect
50
53
  try {
51
54
  await sendRequest({ action: 'ping' });
@@ -70,9 +73,11 @@ function getServerSpawnArgs() {
70
73
  }
71
74
  }
72
75
  catch { }
73
- // Fallback: use the globally installed agents command
76
+ // Fallback: use the globally installed agents command. `which` is Unix-only;
77
+ // Windows uses `where`, which can return multiple lines — take the first.
74
78
  try {
75
- const agentsBin = execSync('which agents', { encoding: 'utf-8' }).trim();
79
+ const lookup = IS_WINDOWS ? 'where agents' : 'which agents';
80
+ const agentsBin = execSync(lookup, { encoding: 'utf-8' }).split(/\r?\n/)[0].trim();
76
81
  if (agentsBin) {
77
82
  return { bin: agentsBin, args: ['pty', '_server'] };
78
83
  }
@@ -86,7 +91,10 @@ function getServerSpawnArgs() {
86
91
  function sendRequest(req) {
87
92
  return new Promise((resolve, reject) => {
88
93
  const socketPath = getSocketPath();
89
- if (!fs.existsSync(socketPath)) {
94
+ // On Unix a missing socket file means the server isn't up — fail fast with a
95
+ // clear message. On Windows the named pipe isn't a filesystem object, so we
96
+ // skip the probe and let createConnection surface ENOENT/connection errors.
97
+ if (!IS_WINDOWS && !fs.existsSync(socketPath)) {
90
98
  reject(new Error('PTY server socket not found. Is the server running?'));
91
99
  return;
92
100
  }
@@ -18,7 +18,30 @@
18
18
  * Returns null on any error so callers can skip the guard rather than crash.
19
19
  */
20
20
  export declare function captureProcessStartTime(pid: number): string | null;
21
- /** Get the unix socket path for the PTY server. */
21
+ /**
22
+ * Wrap a user command so a `__SENTINEL__:<exit>` line is printed after it
23
+ * finishes — that line drives completion detection in the exec/read flow.
24
+ * The separator and exit-code variable are shell-family specific:
25
+ * POSIX sh/zsh/bash : `cmd; echo "S:$?"`
26
+ * PowerShell : `cmd; echo "S:$LASTEXITCODE"`
27
+ * cmd.exe : `cmd & echo S:%errorlevel%` (`&` always runs the echo)
28
+ * Only the completion marker matters; the numeric exit code is informational
29
+ * (the authoritative code comes from node-pty's onExit).
30
+ */
31
+ export declare function buildSentinelCommand(shell: string, command: string): string;
32
+ /**
33
+ * Resolve the IPC endpoint for a given platform + PTY scratch dir. Pure so both
34
+ * branches are testable without stubbing process.platform.
35
+ *
36
+ * Unix: an AF_UNIX socket file inside the scratch dir.
37
+ * Windows: a named pipe (`\\.\pipe\…`). Named pipes are NOT filesystem objects,
38
+ * so the name is derived from a hash of the (per-user) scratch dir to keep it
39
+ * stable across invocations and isolated per user — and callers must never probe
40
+ * it with fs.existsSync (it always reports false). Both forms are accepted by
41
+ * net.createServer/createConnection.
42
+ */
43
+ export declare function derivePtyEndpoint(platform: NodeJS.Platform, ptyDir: string): string;
44
+ /** Get the IPC endpoint the PTY server listens on / clients connect to. */
22
45
  export declare function getSocketPath(): string;
23
46
  /** Get the path to the PTY server PID file. */
24
47
  export declare function getPtyPidPath(): string;
@@ -15,6 +15,7 @@ import * as crypto from 'crypto';
15
15
  import { execFileSync } from 'child_process';
16
16
  import { fileURLToPath } from 'url';
17
17
  import { getPtyDir as getPtyDirRoot } from './state.js';
18
+ import { isAlive } from './platform/index.js';
18
19
  /**
19
20
  * Capture a stable identifier for a process at the moment it was started.
20
21
  * Used to defeat PID reuse: a kill(pid, ...) is only safe when the process
@@ -52,6 +53,7 @@ export function captureProcessStartTime(pid) {
52
53
  }
53
54
  }
54
55
  // --- Constants ---
56
+ const IS_WINDOWS = process.platform === 'win32';
55
57
  const SENTINEL = '__AGENTS_PTY_DONE__';
56
58
  const SOCKET_NAME = 'pty.sock';
57
59
  const PID_FILE = 'pty.pid';
@@ -72,24 +74,81 @@ const PTY_ENV_ALLOWLIST = [
72
74
  'EDITOR', 'VISUAL', 'PAGER', 'LESS',
73
75
  'NO_COLOR', 'FORCE_COLOR',
74
76
  ];
77
+ /**
78
+ * Windows allowlist. cmd.exe / PowerShell refuse to start (or misbehave) without
79
+ * SystemRoot, ComSpec, PATHEXT and the USERPROFILE/APPDATA family, so a Unix-style
80
+ * allowlist would spawn a broken shell. PATH/TERM/color/NODE vars are shared with
81
+ * the Unix list; the rest are Windows-specific.
82
+ */
83
+ const PTY_ENV_ALLOWLIST_WIN = [
84
+ 'SystemRoot', 'SystemDrive', 'windir', 'ComSpec', 'PATH', 'PATHEXT',
85
+ 'TEMP', 'TMP', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'HOME',
86
+ 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMDATA',
87
+ 'USERNAME', 'USERDOMAIN', 'COMPUTERNAME', 'OS',
88
+ 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS',
89
+ 'TERM', 'COLORTERM', 'NO_COLOR', 'FORCE_COLOR',
90
+ 'NODE_PATH', 'BUN_INSTALL',
91
+ ];
75
92
  function buildPtyEnv() {
76
93
  const env = {};
77
- for (const key of PTY_ENV_ALLOWLIST) {
94
+ const allowlist = IS_WINDOWS ? PTY_ENV_ALLOWLIST_WIN : PTY_ENV_ALLOWLIST;
95
+ for (const key of allowlist) {
78
96
  const v = process.env[key];
79
97
  if (v !== undefined)
80
98
  env[key] = v;
81
99
  }
82
100
  return env;
83
101
  }
102
+ /**
103
+ * Wrap a user command so a `__SENTINEL__:<exit>` line is printed after it
104
+ * finishes — that line drives completion detection in the exec/read flow.
105
+ * The separator and exit-code variable are shell-family specific:
106
+ * POSIX sh/zsh/bash : `cmd; echo "S:$?"`
107
+ * PowerShell : `cmd; echo "S:$LASTEXITCODE"`
108
+ * cmd.exe : `cmd & echo S:%errorlevel%` (`&` always runs the echo)
109
+ * Only the completion marker matters; the numeric exit code is informational
110
+ * (the authoritative code comes from node-pty's onExit).
111
+ */
112
+ export function buildSentinelCommand(shell, command) {
113
+ // Split on both separators: a Windows shell path (`C:\…\cmd.exe`) must be
114
+ // recognized even when this code runs under POSIX path.basename, which does
115
+ // not treat `\` as a separator.
116
+ const name = (shell.split(/[\\/]/).pop() || shell).toLowerCase();
117
+ if (name === 'cmd.exe' || name === 'cmd') {
118
+ return `${command} & echo ${SENTINEL}:%errorlevel%`;
119
+ }
120
+ if (name === 'powershell.exe' || name === 'powershell' || name === 'pwsh.exe' || name === 'pwsh') {
121
+ return `${command}; echo "${SENTINEL}:$LASTEXITCODE"`;
122
+ }
123
+ return `${command}; echo "${SENTINEL}:$?"`;
124
+ }
84
125
  /** Get the PTY helper directory, creating it if needed. */
85
126
  function getPtyDir() {
86
127
  const dir = getPtyDirRoot();
87
128
  fs.mkdirSync(dir, { recursive: true });
88
129
  return dir;
89
130
  }
90
- /** Get the unix socket path for the PTY server. */
131
+ /**
132
+ * Resolve the IPC endpoint for a given platform + PTY scratch dir. Pure so both
133
+ * branches are testable without stubbing process.platform.
134
+ *
135
+ * Unix: an AF_UNIX socket file inside the scratch dir.
136
+ * Windows: a named pipe (`\\.\pipe\…`). Named pipes are NOT filesystem objects,
137
+ * so the name is derived from a hash of the (per-user) scratch dir to keep it
138
+ * stable across invocations and isolated per user — and callers must never probe
139
+ * it with fs.existsSync (it always reports false). Both forms are accepted by
140
+ * net.createServer/createConnection.
141
+ */
142
+ export function derivePtyEndpoint(platform, ptyDir) {
143
+ if (platform === 'win32') {
144
+ const hash = crypto.createHash('sha1').update(ptyDir).digest('hex').slice(0, 16);
145
+ return `\\\\.\\pipe\\agents-pty-${hash}`;
146
+ }
147
+ return path.join(ptyDir, SOCKET_NAME);
148
+ }
149
+ /** Get the IPC endpoint the PTY server listens on / clients connect to. */
91
150
  export function getSocketPath() {
92
- return path.join(getPtyDir(), SOCKET_NAME);
151
+ return derivePtyEndpoint(process.platform, getPtyDir());
93
152
  }
94
153
  /** Get the path to the PTY server PID file. */
95
154
  export function getPtyPidPath() {
@@ -109,16 +168,17 @@ export function isPtyServerRunning() {
109
168
  const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
110
169
  if (isNaN(pid))
111
170
  return false;
112
- process.kill(pid, 0);
113
- return true;
171
+ if (isAlive(pid))
172
+ return true;
114
173
  }
115
174
  catch {
116
- try {
117
- fs.unlinkSync(pidPath);
118
- }
119
- catch { }
120
- return false;
175
+ // read failed — fall through and treat the pid file as stale
121
176
  }
177
+ try {
178
+ fs.unlinkSync(pidPath);
179
+ }
180
+ catch { }
181
+ return false;
122
182
  }
123
183
  // --- Logging ---
124
184
  function rotateLogsIfNeeded(logPath) {
@@ -221,8 +281,10 @@ export async function runPtyServer() {
221
281
  fs.unlinkSync(pidPath);
222
282
  }
223
283
  catch { } });
224
- // Remove stale socket from a prior crashed server. Safe now that we hold the PID slot.
225
- if (fs.existsSync(socketPath)) {
284
+ // Remove stale socket from a prior crashed server. Safe now that we hold the PID
285
+ // slot. Windows named pipes are not filesystem inodes — they vanish with their
286
+ // owning process, so there's nothing to unlink (and existsSync always reports false).
287
+ if (!IS_WINDOWS && fs.existsSync(socketPath)) {
226
288
  try {
227
289
  fs.unlinkSync(socketPath);
228
290
  }
@@ -286,8 +348,10 @@ export async function runPtyServer() {
286
348
  case 'start': {
287
349
  const rows = req.params?.rows || 24;
288
350
  const cols = req.params?.cols || 120;
289
- const shell = req.params?.shell || process.env.SHELL || 'zsh';
290
- const cwd = req.params?.cwd || process.env.HOME || '/';
351
+ const shell = req.params?.shell
352
+ || (IS_WINDOWS ? (process.env.ComSpec || 'powershell.exe') : (process.env.SHELL || 'zsh'));
353
+ const cwd = req.params?.cwd
354
+ || (IS_WINDOWS ? (process.env.USERPROFILE || process.env.HOME || process.cwd()) : (process.env.HOME || '/'));
291
355
  const id = generateId();
292
356
  let ptyProcess;
293
357
  try {
@@ -358,7 +422,9 @@ export async function runPtyServer() {
358
422
  session.appActive = true;
359
423
  session.activeCommand = command;
360
424
  session.pendingOutput = '';
361
- session.pty.write(`${command}; echo "${SENTINEL}:$?"\n`);
425
+ // Windows conpty submits on CR; POSIX line discipline expects LF.
426
+ const submit = IS_WINDOWS ? '\r' : '\n';
427
+ session.pty.write(`${buildSentinelCommand(session.shell, command)}${submit}`);
362
428
  session.lastActivity = Date.now();
363
429
  return { ok: true, submitted: true };
364
430
  }
@@ -525,20 +591,28 @@ export async function runPtyServer() {
525
591
  // any local user with execute on the parent dir could connect to the socket
526
592
  // during the listen()-to-chmod() window. macOS BSD AF_UNIX semantics make
527
593
  // socket mode advisory only, so the parent dir is the real boundary.
594
+ //
595
+ // On Windows the transport is a named pipe, not a filesystem inode: chmod/umask
596
+ // are no-ops (and umask throws in some Node builds), and pipe ACLs default to
597
+ // the creating user. So we skip the Unix hardening entirely there.
528
598
  const agentsDir = getPtyDirRoot();
529
599
  fs.mkdirSync(agentsDir, { recursive: true });
530
- fs.chmodSync(agentsDir, 0o700);
531
- // umask covers any inherited group/other bits while listen() is creating
532
- // the socket inode it only matters for the unobservable instant before
533
- // we can chmod the inode itself.
534
- process.umask(0o077);
600
+ if (!IS_WINDOWS) {
601
+ fs.chmodSync(agentsDir, 0o700);
602
+ // umask covers any inherited group/other bits while listen() is creating
603
+ // the socket inode — it only matters for the unobservable instant before
604
+ // we can chmod the inode itself.
605
+ process.umask(0o077);
606
+ }
535
607
  await new Promise((resolve) => {
536
608
  server.listen(socketPath, () => resolve());
537
609
  });
538
610
  // Surface chmod failures: a 0o600 socket is a load-bearing security
539
611
  // assumption, not a nice-to-have. If we can't lock it down, refuse to
540
- // start so the caller learns immediately.
541
- fs.chmodSync(socketPath, 0o600);
612
+ // start so the caller learns immediately. (No-op on Windows named pipes.)
613
+ if (!IS_WINDOWS) {
614
+ fs.chmodSync(socketPath, 0o600);
615
+ }
542
616
  log('INFO', `PTY server started (PID: ${process.pid}, socket: ${socketPath})`);
543
617
  // Shutdown handler
544
618
  function shutdown() {
@@ -549,10 +623,13 @@ export async function runPtyServer() {
549
623
  sessions.clear();
550
624
  clearInterval(cleanupInterval);
551
625
  server.close();
552
- try {
553
- fs.unlinkSync(socketPath);
626
+ // Named pipes are reclaimed by the OS on close; only Unix sockets leave a file.
627
+ if (!IS_WINDOWS) {
628
+ try {
629
+ fs.unlinkSync(socketPath);
630
+ }
631
+ catch { }
554
632
  }
555
- catch { }
556
633
  try {
557
634
  fs.unlinkSync(getPtyPidPath());
558
635
  }
@@ -8,6 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { parseSession } from './parse.js';
11
+ import { toComparablePath } from '../platform/index.js';
11
12
  /** Tool names that produce file artifacts (writes, edits, patches). */
12
13
  const WRITE_TOOLS = new Set([
13
14
  'Write', 'Edit', 'write_file', 'edit_file', 'create_file', 'replace', 'patch',
@@ -87,8 +88,13 @@ export function resolveArtifact(artifacts, name) {
87
88
  return byBase[0];
88
89
  if (byBase.length > 1)
89
90
  return byBase[0];
90
- // Path suffix match (e.g. "src/foo.ts")
91
- const bySuffix = artifacts.filter(a => a.path.endsWith('/' + name) || a.path === name);
91
+ // Path suffix match (e.g. "src/foo.ts"). Normalize so Windows `\` paths match
92
+ // a forward-slash query too; on POSIX this is identical to the old comparison.
93
+ const target = toComparablePath(name);
94
+ const bySuffix = artifacts.filter(a => {
95
+ const ap = toComparablePath(a.path);
96
+ return ap.endsWith('/' + target) || ap === target;
97
+ });
92
98
  if (bySuffix.length >= 1)
93
99
  return bySuffix[0];
94
100
  return null;
package/dist/lib/shims.js CHANGED
@@ -13,6 +13,7 @@ import * as path from 'path';
13
13
  import * as os from 'os';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { confirm, select } from '@inquirer/prompts';
16
+ import { IS_WINDOWS } from './platform/index.js';
16
17
  import { getShimsDir, getVersionsDir, getBackupsDir, ensureAgentsDir } from './state.js';
17
18
  export { getShimsDir };
18
19
  import { AGENTS } from './agents.js';
@@ -521,8 +522,28 @@ export function createShim(agent) {
521
522
  const shimPath = path.join(shimsDir, agentConfig.cliCommand);
522
523
  const script = generateShimScript(agent);
523
524
  fs.writeFileSync(shimPath, script, { mode: 0o755 });
525
+ // Windows can't execute the bash shim directly. Drop a `.cmd` companion next
526
+ // to it that delegates to the node-side transparent resolver (`agents __shim`),
527
+ // so the version resolution stays single-sourced instead of reimplemented in batch.
528
+ if (IS_WINDOWS) {
529
+ writeWindowsCmdShim(shimPath + '.cmd', agentConfig.cliCommand);
530
+ }
524
531
  return shimPath;
525
532
  }
533
+ /**
534
+ * Generate a Windows `.cmd` launcher that delegates to `agents __shim <spec>`.
535
+ * `spec` is the agent's cliCommand for the default-version shim, or
536
+ * `cliCommand@version` for a versioned alias. node + the dist entrypoint are
537
+ * resolved at generation time so the launcher does not depend on `agents`
538
+ * already being on PATH.
539
+ */
540
+ function writeWindowsCmdShim(cmdPath, spec) {
541
+ const indexJs = getAgentsBinForGeneratedShim();
542
+ const content = `@echo off\r\n` +
543
+ `rem Auto-generated by agents-cli - do not edit\r\n` +
544
+ `node "${indexJs}" __shim ${spec} %*\r\n`;
545
+ fs.writeFileSync(cmdPath, content);
546
+ }
526
547
  /**
527
548
  * Remove the shim for an agent.
528
549
  */
@@ -532,6 +553,12 @@ export function removeShim(agent) {
532
553
  const shimPath = path.join(shimsDir, agentConfig.cliCommand);
533
554
  if (fs.existsSync(shimPath)) {
534
555
  fs.unlinkSync(shimPath);
556
+ if (IS_WINDOWS) {
557
+ try {
558
+ fs.unlinkSync(shimPath + '.cmd');
559
+ }
560
+ catch { }
561
+ }
535
562
  return true;
536
563
  }
537
564
  return false;
@@ -682,6 +709,9 @@ export function createVersionedAlias(agent, version) {
682
709
  const aliasPath = path.join(shimsDir, `${agentConfig.cliCommand}@${version}`);
683
710
  const script = generateVersionedAliasScript(agent, version);
684
711
  fs.writeFileSync(aliasPath, script, { mode: 0o755 });
712
+ if (IS_WINDOWS) {
713
+ writeWindowsCmdShim(aliasPath + '.cmd', `${agentConfig.cliCommand}@${version}`);
714
+ }
685
715
  return aliasPath;
686
716
  }
687
717
  /**
@@ -693,6 +723,12 @@ export function removeVersionedAlias(agent, version) {
693
723
  const aliasPath = path.join(shimsDir, `${agentConfig.cliCommand}@${version}`);
694
724
  if (fs.existsSync(aliasPath)) {
695
725
  fs.unlinkSync(aliasPath);
726
+ if (IS_WINDOWS) {
727
+ try {
728
+ fs.unlinkSync(aliasPath + '.cmd');
729
+ }
730
+ catch { }
731
+ }
696
732
  return true;
697
733
  }
698
734
  return false;
@@ -1488,6 +1524,15 @@ Then restart your shell or run:
1488
1524
  * Returns true if added, false if already present or failed.
1489
1525
  */
1490
1526
  export function addShimsToPath(overrides) {
1527
+ // Windows has no shell rc file to edit: the primary `agents` command is already
1528
+ // on PATH via npm's global bin, and bare shorthands / versioned aliases are
1529
+ // handled by the `.cmd` shims plus the PATH guidance printed at install time.
1530
+ // Report "already present" so callers don't emit a misleading "added to
1531
+ // ~/.bashrc / source ~/.bashrc" message. (The `shell` override is the test hook
1532
+ // for exercising the POSIX path, so it bypasses this short-circuit.)
1533
+ if (IS_WINDOWS && !overrides?.shell) {
1534
+ return { success: true, alreadyPresent: true };
1535
+ }
1491
1536
  const shimsDir = overrides?.shimsDir || getShimsDir();
1492
1537
  const { rcFile, rcPath, shell } = getShellRcFile(overrides);
1493
1538
  // Read current rc file content
@@ -14,6 +14,7 @@ import * as path from 'path';
14
14
  import * as os from 'os';
15
15
  import { randomUUID } from 'crypto';
16
16
  import { resolveAgentsDir } from './persistence.js';
17
+ import { findExecutable } from '../platform/index.js';
17
18
  import { normalizeEvents } from './parsers.js';
18
19
  import { debug } from './debug.js';
19
20
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
@@ -300,13 +301,10 @@ export function checkCliAvailable(agentType) {
300
301
  if (!executable) {
301
302
  return [false, `Unknown agent type: ${agentType}`];
302
303
  }
303
- try {
304
- const whichPath = execFileSync('which', [executable], { encoding: 'utf-8' }).trim();
305
- return [true, whichPath];
306
- }
307
- catch {
308
- return [false, `CLI tool '${executable}' not found in PATH. Install it first.`];
309
- }
304
+ const resolved = findExecutable(executable);
305
+ return resolved
306
+ ? [true, resolved]
307
+ : [false, `CLI tool '${executable}' not found in PATH. Install it first.`];
310
308
  }
311
309
  /** Check availability of all known agent CLIs. Returns a map of agent type to install status. */
312
310
  export function checkAllClis() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.6",
3
+ "version": "1.20.7",
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",
@@ -95,6 +95,11 @@ function writeAliasShims() {
95
95
  const target = path.join(SHIMS_DIR, name);
96
96
  const script = `#!/bin/sh\nAGENTS_BIN=${shellQuote(AGENTS_BIN)}\nif [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then\n echo "agents: agents-cli entrypoint missing or not executable: $AGENTS_BIN" >&2\n exit 127\nfi\nexec "$AGENTS_BIN" ${name} "$@"\n`;
97
97
  fs.writeFileSync(target, script, { mode: 0o755 });
98
+ // Windows can't run the POSIX shim; drop a `.cmd` companion that invokes the
99
+ // entrypoint via node so the bare shorthand works in a Windows shell.
100
+ if (process.platform === 'win32') {
101
+ fs.writeFileSync(target + '.cmd', `@echo off\r\nnode "${AGENTS_BIN}" ${name} %*\r\n`);
102
+ }
98
103
  written.push(name);
99
104
  }
100
105
  return written;
@@ -139,8 +144,20 @@ function isAlreadyConfigured(rcFile) {
139
144
  }
140
145
 
141
146
  async function main() {
147
+ // Windows has no shell rc files to edit. Write the `.cmd` shorthands and point
148
+ // the user at the PATH entry they can add (the primary `agents` command is
149
+ // already on PATH via npm's global bin, so this only affects bare shorthands
150
+ // and versioned aliases).
151
+ if (process.platform === 'win32') {
152
+ console.log(`\nagents-cli installed.`);
153
+ const written = writeAliasShims();
154
+ console.log(` Installed shorthands: ${written.join(', ')}`);
155
+ console.log(`\nTo use bare shorthands (${ALIASES.join(', ')}) and versioned aliases, add this to your PATH:`);
156
+ console.log(` ${SHIMS_DIR}`);
157
+ console.log(` PowerShell: setx PATH "$env:PATH;${SHIMS_DIR}" (then open a new terminal)`);
158
+ }
142
159
  // Opt-in: AGENTS_INIT_SHELL=1 npm install -g @phnx-labs/agents-cli
143
- if (process.env.AGENTS_INIT_SHELL === '1') {
160
+ else if (process.env.AGENTS_INIT_SHELL === '1') {
144
161
  const rcFile = getShellRc();
145
162
  if (!isAlreadyConfigured(rcFile)) {
146
163
  const addition = `\n# agents-cli: version switching for AI coding agents\n${exportLine}\n`;