@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.
@@ -1,13 +1,17 @@
1
1
  /**
2
- * `agents inspect <agent>[@version]`single-agent, single-version detail view.
2
+ * `agents inspect <target>`detail view for one agent+version or one DotAgents repo.
3
3
  *
4
- * Summary mode shows the per-version header (paths, shim, capabilities, resource
5
- * counts, sessions). Drill-down flags (`--skills`, `--hooks`, `--mcp`, ...) list
6
- * one resource kind; passing a positional query to the same flag fuzzy-searches
7
- * for a single resource and prints its detail. Resource names render as OSC-8
8
- * hyperlinks to the marker file (SKILL.md / WORKFLOW.md / AGENT.md / the file
9
- * itself) so users can click straight to the source.
4
+ * Agent targets (`claude`, `claude@2.1.170`) show the per-version header (paths,
5
+ * shim, capabilities, resource counts, sessions). Repo targets (`user`, `system`,
6
+ * `project`, a registered extra-repo alias, or a filesystem path to a repo with a
7
+ * `.agents/` dir or to a DotAgents root itself) show the repo root, git state, and
8
+ * per-kind resource counts. Drill-down flags (`--skills`, `--hooks`, `--mcp`, ...)
9
+ * list one resource kind for either target form; passing a positional query to the
10
+ * same flag fuzzy-searches for a single resource and prints its detail. Resource
11
+ * names render as OSC-8 hyperlinks to the marker file (SKILL.md / WORKFLOW.md /
12
+ * AGENT.md / the file itself) so users can click straight to the source.
10
13
  */
14
+ import { execSync } from 'child_process';
11
15
  import * as fs from 'fs';
12
16
  import * as os from 'os';
13
17
  import * as path from 'path';
@@ -15,7 +19,7 @@ import chalk from 'chalk';
15
19
  import * as yaml from 'yaml';
16
20
  import { AGENTS, getCliState } from '../lib/agents.js';
17
21
  import { supports } from '../lib/capabilities.js';
18
- import { readMeta } from '../lib/state.js';
22
+ import { readMeta, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../lib/state.js';
19
23
  import { getVersionHomePath } from '../lib/versions.js';
20
24
  import { getShimsDir, getVersionedAliasPath } from '../lib/shims.js';
21
25
  import { getAgentResources, listResources, } from '../lib/resources.js';
@@ -39,8 +43,8 @@ const CAPABILITY_NAMES = [
39
43
  // ─── Command registration ────────────────────────────────────────────────────
40
44
  export function registerInspectCommand(program) {
41
45
  const cmd = program
42
- .command('inspect <agent>')
43
- .description('Inspect one installed agent at one version — paths, capabilities, resources, drill into any kind.')
46
+ .command('inspect <target>')
47
+ .description('Inspect one installed agent at one version, or a DotAgents repo (user|system|project|alias|path) — paths, capabilities, resources, drill into any kind.')
44
48
  .option('--brief', 'header + capabilities only; skip resources/sessions')
45
49
  .option('--json', 'machine-readable JSON output');
46
50
  for (const kind of DRILLABLE_KINDS) {
@@ -52,6 +56,20 @@ export function registerInspectCommand(program) {
52
56
  }
53
57
  // ─── Main dispatcher ─────────────────────────────────────────────────────────
54
58
  export async function inspectAction(target, options) {
59
+ const agentKey = target.split('@')[0].toLowerCase();
60
+ if (!(agentKey in AGENTS)) {
61
+ const repo = resolveRepoTarget(target);
62
+ if (repo) {
63
+ await inspectRepo(repo, options);
64
+ return;
65
+ }
66
+ const extras = getEnabledExtraRepos();
67
+ console.error(chalk.red(`Unknown target: ${target}`));
68
+ console.error(chalk.gray(`Agents: ${Object.keys(AGENTS).join(', ')}`));
69
+ const aliases = extras.length > 0 ? `, ${extras.map(e => e.alias).join(', ')}` : '';
70
+ console.error(chalk.gray(`Repos: user, system, project${aliases} — or a path to a repo with a .agents/ dir`));
71
+ process.exit(1);
72
+ }
55
73
  const { agent, version } = parseTarget(target);
56
74
  const versionHome = getVersionHomePath(agent, version);
57
75
  if (!fs.existsSync(versionHome)) {
@@ -75,13 +93,7 @@ export async function inspectAction(target, options) {
75
93
  }
76
94
  function parseTarget(target) {
77
95
  const [rawAgent, rawVersion] = target.split('@');
78
- const agentKey = (rawAgent || '').toLowerCase();
79
- if (!(agentKey in AGENTS)) {
80
- console.error(chalk.red(`Unknown agent: ${rawAgent}`));
81
- console.error(chalk.gray(`Known agents: ${Object.keys(AGENTS).join(', ')}`));
82
- process.exit(1);
83
- }
84
- const agent = agentKey;
96
+ const agent = (rawAgent || '').toLowerCase();
85
97
  let version = rawVersion;
86
98
  if (!version || version === 'default') {
87
99
  const meta = readMeta();
@@ -110,6 +122,160 @@ function pickDrillKind(options) {
110
122
  }
111
123
  return active[0];
112
124
  }
125
+ /** Files at a DotAgents root that mark it as one, beyond the per-kind dirs. */
126
+ const REPO_MARKER_FILES = ['agents.yaml', 'hooks.yaml'];
127
+ /**
128
+ * Resolve a non-agent target as a DotAgents repo: the built-in layer names,
129
+ * a registered extra-repo alias, or a filesystem path. Paths accept either a
130
+ * DotAgents root itself or a repo whose `.agents/` dir should be inspected.
131
+ * Returns null when the target is none of these.
132
+ */
133
+ export function resolveRepoTarget(target, cwd) {
134
+ if (target === 'user')
135
+ return { label: 'user', root: getUserAgentsDir() };
136
+ if (target === 'system')
137
+ return { label: 'system', root: getSystemAgentsDir() };
138
+ if (target === 'project') {
139
+ const dir = getProjectAgentsDir(cwd);
140
+ if (!dir) {
141
+ console.error(chalk.red('No project .agents/ directory found from the current directory.'));
142
+ process.exit(1);
143
+ }
144
+ return { label: 'project', root: dir };
145
+ }
146
+ for (const extra of getEnabledExtraRepos()) {
147
+ if (extra.alias === target)
148
+ return { label: extra.alias, root: extra.dir };
149
+ }
150
+ const expanded = target.startsWith('~/') ? path.join(os.homedir(), target.slice(2)) : target;
151
+ const abs = path.resolve(cwd ?? process.cwd(), expanded);
152
+ const stat = safeStat(abs);
153
+ if (!stat || !stat.isDirectory())
154
+ return null;
155
+ // A dir that is itself a DotAgents root wins over its nested .agents/ —
156
+ // extra repos like ~/.agents-extras keep resources at the top level and use
157
+ // .agents/ only for worktrees.
158
+ if (isDotAgentsRoot(abs)) {
159
+ const label = path.basename(abs) === '.agents' ? path.basename(path.dirname(abs)) : path.basename(abs);
160
+ return { label, root: abs };
161
+ }
162
+ if (path.basename(abs) !== '.agents') {
163
+ const nested = path.join(abs, '.agents');
164
+ if (safeStat(nested)?.isDirectory()) {
165
+ return { label: path.basename(abs), root: nested };
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+ function isDotAgentsRoot(dir) {
171
+ for (const marker of REPO_MARKER_FILES) {
172
+ if (fs.existsSync(path.join(dir, marker)))
173
+ return true;
174
+ }
175
+ for (const kind of DRILLABLE_KINDS) {
176
+ if (safeStat(path.join(dir, kind))?.isDirectory())
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+ async function inspectRepo(repo, options) {
182
+ const drill = pickDrillKind(options);
183
+ const jsonHead = { repo: repo.label, root: repo.root };
184
+ if (drill) {
185
+ const items = collectRepoKind(repo, drill.kind);
186
+ if (drill.query === true || drill.query === undefined) {
187
+ renderItemList(repo.label, jsonHead, drill.kind, items, options);
188
+ }
189
+ else {
190
+ renderItemDetail(repo.label, jsonHead, drill.kind, String(drill.query), items, options);
191
+ }
192
+ return;
193
+ }
194
+ renderRepoSummary(repo, options);
195
+ }
196
+ /** List one resource kind from a single repo root — no layering, no overrides. */
197
+ export function collectRepoKind(repo, kind) {
198
+ const dir = path.join(repo.root, kind);
199
+ let entries;
200
+ try {
201
+ entries = fs.readdirSync(dir, { withFileTypes: true });
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ const items = [];
207
+ for (const entry of entries) {
208
+ if (entry.name.startsWith('.'))
209
+ continue;
210
+ const p = path.join(dir, entry.name);
211
+ items.push({
212
+ name: entry.name.replace(/\.(md|yaml|yml|toml|json)$/, ''),
213
+ source: repo.label,
214
+ path: p,
215
+ linkTarget: linkTarget(p),
216
+ description: readDescription(p),
217
+ });
218
+ }
219
+ return items.sort((a, b) => a.name.localeCompare(b.name));
220
+ }
221
+ function renderRepoSummary(repo, options) {
222
+ const git = repoGitInfo(repo.root);
223
+ const manifests = REPO_MARKER_FILES.filter(m => fs.existsSync(path.join(repo.root, m)));
224
+ const counts = {};
225
+ if (!options.brief) {
226
+ for (const kind of DRILLABLE_KINDS) {
227
+ const items = collectRepoKind(repo, kind);
228
+ counts[kind] = { total: items.length, bySource: { [repo.label]: items.length } };
229
+ }
230
+ }
231
+ if (options.json) {
232
+ console.log(JSON.stringify({
233
+ repo: repo.label,
234
+ root: repo.root,
235
+ git,
236
+ manifests,
237
+ resources: options.brief ? null : Object.fromEntries(DRILLABLE_KINDS.map(kind => [kind, counts[kind].total])),
238
+ }, null, 2));
239
+ return;
240
+ }
241
+ console.log('\n' + chalk.bold(repo.label) + ' ' + chalk.gray('[dotagents repo]') + '\n');
242
+ const rows = [['root', termLink(repo.root, repo.root)]];
243
+ if (git) {
244
+ const dirty = git.dirty > 0 ? ` ${chalk.gray('·')} ${chalk.yellow(`${git.dirty} dirty`)}` : '';
245
+ const url = git.url ? ` ${chalk.gray('·')} ${chalk.gray(git.url)}` : '';
246
+ rows.push(['git', `${git.branch}${dirty}${url}`]);
247
+ }
248
+ if (manifests.length > 0)
249
+ rows.push(['manifests', manifests.join(', ')]);
250
+ for (const [k, v] of rows)
251
+ console.log(` ${k.padEnd(10)} ${v}`);
252
+ if (!options.brief) {
253
+ console.log('\n' + chalk.bold('Resources'));
254
+ for (const kind of DRILLABLE_KINDS) {
255
+ console.log(` ${kind.padEnd(10)} ${String(counts[kind].total).padStart(4)}`);
256
+ }
257
+ }
258
+ console.log('');
259
+ console.log(chalk.gray(`Drill in: agents inspect ${repo.label} --skills <query>`));
260
+ console.log('');
261
+ }
262
+ function repoGitInfo(root) {
263
+ const git = (args) => {
264
+ try {
265
+ return execSync(`git -C ${JSON.stringify(root)} ${args}`, { stdio: ['ignore', 'pipe', 'ignore'] })
266
+ .toString().trim();
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ };
272
+ const branch = git('rev-parse --abbrev-ref HEAD');
273
+ if (branch === null)
274
+ return null;
275
+ const status = git('status --porcelain');
276
+ const dirty = status ? status.split('\n').filter(Boolean).length : 0;
277
+ return { branch, dirty, url: git('remote get-url origin') };
278
+ }
113
279
  // ─── Summary mode ────────────────────────────────────────────────────────────
114
280
  async function renderSummary(agent, version, versionHome, options) {
115
281
  const meta = readMeta();
@@ -184,17 +350,19 @@ async function renderSummary(agent, version, versionHome, options) {
184
350
  // ─── List mode ───────────────────────────────────────────────────────────────
185
351
  async function renderList(agent, version, versionHome, kind, options) {
186
352
  const items = collectKind(agent, versionHome, kind);
353
+ renderItemList(`${agent}@${version}`, { agent, version }, kind, items, options);
354
+ }
355
+ function renderItemList(header, jsonHead, kind, items, options) {
187
356
  if (options.json) {
188
357
  console.log(JSON.stringify({
189
- agent,
190
- version,
358
+ ...jsonHead,
191
359
  kind,
192
360
  count: items.length,
193
361
  items: items.map(i => ({ name: i.name, source: i.source, path: i.path, description: i.description })),
194
362
  }, null, 2));
195
363
  return;
196
364
  }
197
- console.log('\n' + chalk.bold(`${agent}@${version}`) + ' ' + chalk.gray(`${kind} (${items.length})`) + '\n');
365
+ console.log('\n' + chalk.bold(header) + ' ' + chalk.gray(`${kind} (${items.length})`) + '\n');
198
366
  if (items.length === 0) {
199
367
  console.log(chalk.gray(` (none installed)`));
200
368
  console.log('');
@@ -212,11 +380,14 @@ async function renderList(agent, version, versionHome, kind, options) {
212
380
  // ─── Detail mode (fuzzy) ─────────────────────────────────────────────────────
213
381
  async function renderDetail(agent, version, versionHome, kind, query, options) {
214
382
  const items = collectKind(agent, versionHome, kind);
383
+ renderItemDetail(`${agent}@${version}`, { agent, version }, kind, query, items, options);
384
+ }
385
+ function renderItemDetail(header, jsonHead, kind, query, items, options) {
215
386
  const matches = findMatches(items, query);
216
387
  if (matches.length === 0) {
217
388
  const suggestions = suggestClosest(items, query, 3);
218
389
  if (options.json) {
219
- console.log(JSON.stringify({ agent, version, kind, query, match: null, suggestions: suggestions.map(s => s.name) }, null, 2));
390
+ console.log(JSON.stringify({ ...jsonHead, kind, query, match: null, suggestions: suggestions.map(s => s.name) }, null, 2));
220
391
  }
221
392
  else {
222
393
  console.error(chalk.red(`No ${kind} matching '${query}'.`));
@@ -231,8 +402,7 @@ async function renderDetail(agent, version, versionHome, kind, query, options) {
231
402
  if (options.json) {
232
403
  const detail = buildDetail(best.item, kind);
233
404
  console.log(JSON.stringify({
234
- agent,
235
- version,
405
+ ...jsonHead,
236
406
  kind,
237
407
  query,
238
408
  match: { ...detail, matchKind: best.matchKind },
@@ -240,7 +410,7 @@ async function renderDetail(agent, version, versionHome, kind, query, options) {
240
410
  }, null, 2));
241
411
  return;
242
412
  }
243
- console.log('\n' + chalk.bold(`${agent}@${version}`) + ' ' + chalk.gray(`${kind} matching "${query}"`) + '\n');
413
+ console.log('\n' + chalk.bold(header) + ' ' + chalk.gray(`${kind} matching "${query}"`) + '\n');
244
414
  const matchTag = best.matchKind === 'exact' ? 'exact' : best.matchKind === 'substring' ? 'substring' : `~${best.distance}`;
245
415
  console.log(` ${chalk.green('✓')} ${termLink(chalk.bold.cyan(best.item.name), best.item.linkTarget)} ${chalk.gray(`[${matchTag}, ${best.item.source}]`)}`);
246
416
  if (best.item.description) {
@@ -15,6 +15,7 @@ import chalk from 'chalk';
15
15
  import ora from 'ora';
16
16
  import { SESSION_AGENTS } from '../lib/session/types.js';
17
17
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
18
+ import { looksLikePath, toComparablePath, homeDir } from '../lib/platform/index.js';
18
19
  import { getActiveSessions } from '../lib/session/active.js';
19
20
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
20
21
  import { filterTeamSessions } from '../lib/session/team-filter.js';
@@ -69,15 +70,6 @@ function createScanProgressTracker(verbs, suffix, spinner) {
69
70
  }
70
71
  const PICKER_RECENT_COUNT = 15;
71
72
  const PICKER_POOL_LIMIT = 200;
72
- /**
73
- * Detect whether a positional argument looks like a filesystem path.
74
- * Naked paths (., ./, ../, /, ~) filter sessions by project directory.
75
- * Everything else is treated as a search query string.
76
- */
77
- function isPathLike(query) {
78
- return query === '.' || query.startsWith('./') || query.startsWith('../')
79
- || query.startsWith('/') || query.startsWith('~');
80
- }
81
73
  /**
82
74
  * Resolve a path-like query to an absolute directory path.
83
75
  */
@@ -153,8 +145,13 @@ function contextColor(context) {
153
145
  function shortCwd(cwd) {
154
146
  if (!cwd)
155
147
  return '-';
156
- const home = os.homedir();
157
- return cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
148
+ const home = homeDir();
149
+ // Compare in normalized form so the `~` shorthand also lands on Windows
150
+ // (case-insensitive, backslash paths); on POSIX this is byte-identical to the
151
+ // previous `cwd.startsWith(home)`. The displayed tail keeps original casing.
152
+ return toComparablePath(cwd).startsWith(toComparablePath(home))
153
+ ? '~' + cwd.slice(home.length)
154
+ : cwd;
158
155
  }
159
156
  function formatStartedAt(startedAtMs) {
160
157
  if (!startedAtMs)
@@ -334,7 +331,7 @@ async function sessionsAction(query, options) {
334
331
  // Path-like queries filter by project directory instead of text search.
335
332
  let pathFilter;
336
333
  let searchQuery;
337
- if (query && isPathLike(query)) {
334
+ if (query && looksLikePath(query)) {
338
335
  const resolved = resolvePathFilter(query);
339
336
  if (!fs.existsSync(resolved)) {
340
337
  console.log(chalk.yellow(`Path not found: ${resolved}`));
package/dist/index.js CHANGED
@@ -103,6 +103,21 @@ import { isInteractiveTerminal, isPromptCancelled } from './commands/utils.js';
103
103
  import { AGENTS } from './lib/agents.js';
104
104
  import { getGlobalDefault, listInstalledVersions } from './lib/versions.js';
105
105
  import { addShimsToPath, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } from './lib/shims.js';
106
+ import { IS_WINDOWS } from './lib/platform/index.js';
107
+ // Transparent shim delegate: the generated Windows `.cmd` shims invoke
108
+ // `agents __shim <agent>[@version] <raw args>`. Intercept here, before commander
109
+ // parses anything, so the agent's own flags (`--help`, `--version`, etc.) pass
110
+ // through completely untouched and we skip registering the full command tree.
111
+ if (process.argv[2] === '__shim') {
112
+ const spec = process.argv[3] || '';
113
+ const rawArgs = process.argv.slice(4);
114
+ const atIndex = spec.indexOf('@');
115
+ const agent = atIndex === -1 ? spec : spec.slice(0, atIndex);
116
+ const pinned = atIndex === -1 ? undefined : spec.slice(atIndex + 1);
117
+ const { execShimPassthrough } = await import('./lib/exec.js');
118
+ const code = await execShimPassthrough(agent, rawArgs, process.cwd(), pinned || undefined);
119
+ process.exit(code);
120
+ }
106
121
  const program = new Command();
107
122
  program
108
123
  .name('agents')
@@ -134,7 +149,7 @@ Agent versions:
134
149
  prune cleanup [target] Remove orphan resources and older duplicate version installs
135
150
  trash Inspect and restore soft-deleted version directories
136
151
  view [agent[@version]] List versions, or inspect one in detail
137
- inspect <agent>[@version] Deep details for one agent+version — paths, capabilities, resources, drill into any kind
152
+ inspect <target> Deep details for one agent+version, or a DotAgents repo (user|system|project|alias|path)
138
153
 
139
154
  Agent configuration (synced across versions):
140
155
  rules Instructions given to agents (CLAUDE.md, etc.)
@@ -461,6 +476,13 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
461
476
  for (const agent of installedAgents) {
462
477
  removeLegacyUserShim(agent);
463
478
  }
479
+ // The remaining flow is rc-file PATH repair, which is POSIX-only. On Windows
480
+ // the shims were just regenerated (incl. `.cmd` companions) above; PATH setup
481
+ // is covered by the install-time guidance, so stop here rather than printing
482
+ // shell-rc instructions that don't apply.
483
+ if (IS_WINDOWS) {
484
+ return;
485
+ }
464
486
  const defaultAgents = installedAgents.filter((agent) => getGlobalDefault(agent));
465
487
  const shadowed = defaultAgents
466
488
  .map((agent) => ({ agent, shadowedBy: getPathShadowingExecutable(agent) }))
@@ -11,6 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
  import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
+ import { isAlive } from './platform/index.js';
14
15
  import { listJobs as listAllJobs } from './routines.js';
15
16
  import { JobScheduler } from './scheduler.js';
16
17
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
@@ -114,14 +115,10 @@ export function isDaemonRunning() {
114
115
  const pid = readDaemonPid();
115
116
  if (!pid)
116
117
  return false;
117
- try {
118
- process.kill(pid, 0);
118
+ if (isAlive(pid))
119
119
  return true;
120
- }
121
- catch {
122
- removeDaemonPid();
123
- return false;
124
- }
120
+ removeDaemonPid();
121
+ return false;
125
122
  }
126
123
  /** Redact values that look like tokens or credentials in a log message. */
127
124
  function redactSecrets(message) {
@@ -95,6 +95,15 @@ export declare const AGENT_COMMANDS: Record<AgentId, AgentCommandTemplate>;
95
95
  export declare function buildExecCommand(options: ExecOptions): string[];
96
96
  /** Spawn an agent and return its exit code. Convenience wrapper over spawnAgent. */
97
97
  export declare function execAgent(options: ExecOptions): Promise<number>;
98
+ /**
99
+ * Transparent passthrough exec for generated shims — the node-side delegate that
100
+ * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
101
+ * project/default) and execs the real binary with the user's RAW args and the
102
+ * per-version env isolation, WITHOUT injecting mode/model/reasoning flags. This
103
+ * mirrors what the POSIX bash shim does inline (`exec $BINARY $launchArgs "$@"`),
104
+ * keeping version resolution in one place instead of reimplementing it in batch.
105
+ */
106
+ export declare function execShimPassthrough(agent: AgentId, rawArgs: string[], cwd: string, pinnedVersion?: string): Promise<number>;
98
107
  /**
99
108
  * Patterns that indicate a rate/usage limit. Matching is intentionally broad
100
109
  * because providers phrase these differently -- Anthropic uses "5-hour limit"
package/dist/lib/exec.js CHANGED
@@ -11,7 +11,7 @@ import * as path from 'path';
11
11
  import { ALL_MODES } from './types.js';
12
12
  import { AGENTS } from './agents.js';
13
13
  import { parseTimeout } from './routines.js';
14
- import { getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
14
+ import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
@@ -367,10 +367,27 @@ export function buildExecCommand(options) {
367
367
  // Resolve to the absolute path of the shim so spawn doesn't depend on PATH —
368
368
  // on Linux installs where the shims dir isn't on PATH, spawning the bare
369
369
  // versioned name fails with ENOENT even though `agents view` shows the agent.
370
+ //
371
+ // On Windows, shims are bash scripts and cannot be executed by spawn() directly.
372
+ // buildExecEnv() already sets the isolation env vars (CLAUDE_CONFIG_DIR, CODEX_HOME,
373
+ // etc.) that the bash shim would set, so we can skip the shim entirely and resolve
374
+ // straight to the real binary via getBinaryPath.
370
375
  if (options.version && cmd.length > 0) {
371
- const versionedName = `${cmd[0]}@${options.version}`;
372
- const absPath = path.join(getShimsDir(), versionedName);
373
- cmd[0] = fs.existsSync(absPath) ? absPath : versionedName;
376
+ if (process.platform === 'win32') {
377
+ const binaryPath = getBinaryPath(options.agent, options.version);
378
+ const binaryPathCmd = binaryPath + '.cmd';
379
+ if (fs.existsSync(binaryPathCmd)) {
380
+ cmd[0] = binaryPathCmd;
381
+ }
382
+ else if (fs.existsSync(binaryPath)) {
383
+ cmd[0] = binaryPath;
384
+ }
385
+ }
386
+ else {
387
+ const versionedName = `${cmd[0]}@${options.version}`;
388
+ const absPath = path.join(getShimsDir(), versionedName);
389
+ cmd[0] = fs.existsSync(absPath) ? absPath : versionedName;
390
+ }
374
391
  }
375
392
  // Add reasoning effort flags (before mode flags for codex -c positioning)
376
393
  // For codex, -c must come before 'exec' subcommand, so we insert at position 1
@@ -458,6 +475,42 @@ export async function execAgent(options) {
458
475
  const { exitCode } = await spawnAgent(options);
459
476
  return exitCode;
460
477
  }
478
+ /**
479
+ * Transparent passthrough exec for generated shims — the node-side delegate that
480
+ * Windows `.cmd` shims call. Resolves the active version (explicit pin, else
481
+ * project/default) and execs the real binary with the user's RAW args and the
482
+ * per-version env isolation, WITHOUT injecting mode/model/reasoning flags. This
483
+ * mirrors what the POSIX bash shim does inline (`exec $BINARY $launchArgs "$@"`),
484
+ * keeping version resolution in one place instead of reimplementing it in batch.
485
+ */
486
+ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
487
+ const version = pinnedVersion ?? resolveVersion(agent, cwd) ?? undefined;
488
+ if (!version || !isVersionInstalled(agent, version)) {
489
+ process.stderr.write(`agents: no installed default for ${agent}. Set one with: agents use ${agent} <version>\n`);
490
+ return 127;
491
+ }
492
+ let binary = getBinaryPath(agent, version);
493
+ if (process.platform === 'win32') {
494
+ // npm ships <cmd>.cmd alongside the bare script on Windows; that's the runnable form.
495
+ const cmdPath = binary + '.cmd';
496
+ if (fs.existsSync(cmdPath))
497
+ binary = cmdPath;
498
+ }
499
+ // The only flag the bash shim injects (codex); everything else is transparent.
500
+ const launchArgs = agent === 'codex' ? ['-c', 'check_for_update_on_startup=false'] : [];
501
+ // mode/effort are required by ExecOptions but unused by buildExecEnv (which only
502
+ // derives the per-version config-dir env); pass the agent's default to satisfy the type.
503
+ const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto' });
504
+ const useShell = process.platform === 'win32' && (!path.isAbsolute(binary) || binary.endsWith('.cmd'));
505
+ return new Promise((resolve) => {
506
+ const child = spawn(binary, [...launchArgs, ...rawArgs], { cwd, stdio: 'inherit', env, shell: useShell });
507
+ child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
508
+ child.on('error', (err) => {
509
+ process.stderr.write(`agents: failed to launch ${agent}: ${err.message}\n`);
510
+ resolve(127);
511
+ });
512
+ });
513
+ }
461
514
  /**
462
515
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
463
516
  *
@@ -495,11 +548,14 @@ async function spawnAgent(options) {
495
548
  const stdio = interactive
496
549
  ? ['inherit', 'inherit', 'inherit']
497
550
  : ['inherit', piped ? 'pipe' : 'inherit', 'pipe'];
551
+ // On Windows, .cmd batch wrappers (npm-installed CLIs) require shell:true
552
+ // whether addressed by name or absolute path.
553
+ const useShell = process.platform === 'win32' && (!path.isAbsolute(executable) || executable.endsWith('.cmd'));
498
554
  const child = spawn(executable, args, {
499
555
  cwd: options.cwd || process.cwd(),
500
556
  stdio,
501
557
  env: buildExecEnv(options),
502
- shell: false,
558
+ shell: useShell,
503
559
  });
504
560
  // Mark startup time (time from function call to process spawn)
505
561
  timer.mark('startup');
@@ -0,0 +1,9 @@
1
+ /** PATH-search command for the platform: `where` on Windows, else `which`. */
2
+ export declare function whichCommand(platform?: NodeJS.Platform): string;
3
+ /**
4
+ * Resolve an executable name to its absolute path via the OS PATH search, or
5
+ * `null` if not found. On Windows `where` can return several lines (one per
6
+ * PATHEXT match, e.g. `agents.cmd` and `agents.ps1`) — the first is the one the
7
+ * shell would actually run, matching `which` semantics on POSIX.
8
+ */
9
+ export declare function findExecutable(name: string, platform?: NodeJS.Platform): string | null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Executable resolution, platform-aware.
3
+ */
4
+ import { execFileSync } from 'child_process';
5
+ /** PATH-search command for the platform: `where` on Windows, else `which`. */
6
+ export function whichCommand(platform = process.platform) {
7
+ return platform === 'win32' ? 'where' : 'which';
8
+ }
9
+ /**
10
+ * Resolve an executable name to its absolute path via the OS PATH search, or
11
+ * `null` if not found. On Windows `where` can return several lines (one per
12
+ * PATHEXT match, e.g. `agents.cmd` and `agents.ps1`) — the first is the one the
13
+ * shell would actually run, matching `which` semantics on POSIX.
14
+ */
15
+ export function findExecutable(name, platform = process.platform) {
16
+ try {
17
+ const out = execFileSync(whichCommand(platform), [name], { encoding: 'utf-8' });
18
+ const first = out.trim().split(/\r?\n/)[0]?.trim();
19
+ return first || null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Platform abstraction — the ONE place OS-divergent behavior is decided.
3
+ *
4
+ * Consumers express intent (`looksLikePath`, `findExecutable`, `isAlive`) instead
5
+ * of scattering `process.platform === 'win32'` checks. Each helper that has an
6
+ * observable branch accepts an explicit `platform` argument (defaulting to
7
+ * `process.platform`), so all three OSes are unit-testable on any host.
8
+ *
9
+ * Modules grow per concern as features land:
10
+ * paths — path classification + normalization
11
+ * exec — executable resolution
12
+ * process — process liveness / control
13
+ * (ipc + shell follow when their consumers migrate off inline branches.)
14
+ */
15
+ export declare const IS_WINDOWS: boolean;
16
+ export declare const IS_MACOS: boolean;
17
+ export declare const IS_LINUX: boolean;
18
+ export * from './paths.js';
19
+ export * from './exec.js';
20
+ export * from './process.js';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Platform abstraction — the ONE place OS-divergent behavior is decided.
3
+ *
4
+ * Consumers express intent (`looksLikePath`, `findExecutable`, `isAlive`) instead
5
+ * of scattering `process.platform === 'win32'` checks. Each helper that has an
6
+ * observable branch accepts an explicit `platform` argument (defaulting to
7
+ * `process.platform`), so all three OSes are unit-testable on any host.
8
+ *
9
+ * Modules grow per concern as features land:
10
+ * paths — path classification + normalization
11
+ * exec — executable resolution
12
+ * process — process liveness / control
13
+ * (ipc + shell follow when their consumers migrate off inline branches.)
14
+ */
15
+ export const IS_WINDOWS = process.platform === 'win32';
16
+ export const IS_MACOS = process.platform === 'darwin';
17
+ export const IS_LINUX = process.platform === 'linux';
18
+ export * from './paths.js';
19
+ export * from './exec.js';
20
+ export * from './process.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Does this positional argument look like a filesystem path (vs a search term)?
3
+ *
4
+ * POSIX markers (`.`, `./`, `../`, `/`, `~`) are recognized on every platform —
5
+ * identical to the long-standing behavior. Windows-only shapes (drive-letter
6
+ * `C:\…`, UNC `\\…`, backslash-relative `.\` / `..\`) are recognized ONLY on
7
+ * win32, so a literal `C:\repo` typed on macOS/Linux still resolves as a search
8
+ * term — i.e. no behavior change off Windows.
9
+ */
10
+ export declare function looksLikePath(query: string, platform?: NodeJS.Platform): boolean;
11
+ /**
12
+ * Normalize a path for comparison/prefix-matching: backslashes folded to forward
13
+ * slashes and lowercased on Windows (its filesystem is case-insensitive). On
14
+ * POSIX the input is returned unchanged, so callers behave exactly as before.
15
+ */
16
+ export declare function toComparablePath(p: string, platform?: NodeJS.Platform): string;
17
+ /**
18
+ * Canonical home directory. Use this instead of `process.env.HOME`, which is
19
+ * unset on Windows (where the home is `USERPROFILE`); `os.homedir()` resolves
20
+ * correctly on all three platforms.
21
+ */
22
+ export declare function homeDir(): string;