@indy2kro/agentenv 1.0.1 → 1.1.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.
Files changed (130) hide show
  1. package/dist/adapters/agent-dirs.d.ts +29 -0
  2. package/dist/adapters/agent-dirs.d.ts.map +1 -0
  3. package/dist/adapters/agent-dirs.js +46 -0
  4. package/dist/adapters/agent-dirs.js.map +1 -0
  5. package/dist/adapters/base.d.ts +7 -1
  6. package/dist/adapters/base.d.ts.map +1 -1
  7. package/dist/adapters/base.js.map +1 -1
  8. package/dist/adapters/claude.d.ts.map +1 -1
  9. package/dist/adapters/claude.js +18 -10
  10. package/dist/adapters/claude.js.map +1 -1
  11. package/dist/adapters/cline.d.ts.map +1 -1
  12. package/dist/adapters/cline.js +2 -2
  13. package/dist/adapters/cline.js.map +1 -1
  14. package/dist/adapters/codex.d.ts.map +1 -1
  15. package/dist/adapters/codex.js +3 -2
  16. package/dist/adapters/codex.js.map +1 -1
  17. package/dist/adapters/copilot.d.ts.map +1 -1
  18. package/dist/adapters/copilot.js +3 -4
  19. package/dist/adapters/copilot.js.map +1 -1
  20. package/dist/adapters/cursor.d.ts.map +1 -1
  21. package/dist/adapters/cursor.js +7 -6
  22. package/dist/adapters/cursor.js.map +1 -1
  23. package/dist/adapters/detect.d.ts.map +1 -1
  24. package/dist/adapters/detect.js +1 -0
  25. package/dist/adapters/detect.js.map +1 -1
  26. package/dist/adapters/gemini.d.ts.map +1 -1
  27. package/dist/adapters/gemini.js +2 -2
  28. package/dist/adapters/gemini.js.map +1 -1
  29. package/dist/adapters/index.d.ts +4 -0
  30. package/dist/adapters/index.d.ts.map +1 -1
  31. package/dist/adapters/index.js +21 -0
  32. package/dist/adapters/index.js.map +1 -1
  33. package/dist/adapters/opencode.d.ts.map +1 -1
  34. package/dist/adapters/opencode.js +2 -2
  35. package/dist/adapters/opencode.js.map +1 -1
  36. package/dist/adapters/rtk-delegation.d.ts.map +1 -1
  37. package/dist/adapters/rtk-delegation.js +16 -1
  38. package/dist/adapters/rtk-delegation.js.map +1 -1
  39. package/dist/adapters/vibe.d.ts.map +1 -1
  40. package/dist/adapters/vibe.js +2 -2
  41. package/dist/adapters/vibe.js.map +1 -1
  42. package/dist/adapters/windsurf.d.ts.map +1 -1
  43. package/dist/adapters/windsurf.js +2 -2
  44. package/dist/adapters/windsurf.js.map +1 -1
  45. package/dist/cli/exit.d.ts +17 -6
  46. package/dist/cli/exit.d.ts.map +1 -1
  47. package/dist/cli/exit.js +17 -7
  48. package/dist/cli/exit.js.map +1 -1
  49. package/dist/commands/apply.d.ts +77 -0
  50. package/dist/commands/apply.d.ts.map +1 -1
  51. package/dist/commands/apply.js +202 -27
  52. package/dist/commands/apply.js.map +1 -1
  53. package/dist/commands/config-edit.d.ts +33 -0
  54. package/dist/commands/config-edit.d.ts.map +1 -0
  55. package/dist/commands/config-edit.js +216 -0
  56. package/dist/commands/config-edit.js.map +1 -0
  57. package/dist/commands/doctor.d.ts +57 -0
  58. package/dist/commands/doctor.d.ts.map +1 -1
  59. package/dist/commands/doctor.js +233 -59
  60. package/dist/commands/doctor.js.map +1 -1
  61. package/dist/commands/setup.d.ts +4 -0
  62. package/dist/commands/setup.d.ts.map +1 -1
  63. package/dist/commands/setup.js +58 -5
  64. package/dist/commands/setup.js.map +1 -1
  65. package/dist/commands/status.d.ts.map +1 -1
  66. package/dist/commands/status.js +40 -16
  67. package/dist/commands/status.js.map +1 -1
  68. package/dist/commands/uninstall.d.ts +78 -1
  69. package/dist/commands/uninstall.d.ts.map +1 -1
  70. package/dist/commands/uninstall.js +329 -31
  71. package/dist/commands/uninstall.js.map +1 -1
  72. package/dist/commands/update.d.ts +51 -0
  73. package/dist/commands/update.d.ts.map +1 -1
  74. package/dist/commands/update.js +172 -71
  75. package/dist/commands/update.js.map +1 -1
  76. package/dist/commands/wizard.d.ts +27 -1
  77. package/dist/commands/wizard.d.ts.map +1 -1
  78. package/dist/commands/wizard.js +48 -31
  79. package/dist/commands/wizard.js.map +1 -1
  80. package/dist/config/schema.d.ts +28 -3
  81. package/dist/config/schema.d.ts.map +1 -1
  82. package/dist/config/schema.js +206 -54
  83. package/dist/config/schema.js.map +1 -1
  84. package/dist/config/scopes.d.ts +4 -2
  85. package/dist/config/scopes.d.ts.map +1 -1
  86. package/dist/config/scopes.js +23 -4
  87. package/dist/config/scopes.js.map +1 -1
  88. package/dist/generate/agentsmd.d.ts +54 -0
  89. package/dist/generate/agentsmd.d.ts.map +1 -1
  90. package/dist/generate/agentsmd.js +222 -143
  91. package/dist/generate/agentsmd.js.map +1 -1
  92. package/dist/index.js +9 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/integrations/render.d.ts.map +1 -1
  95. package/dist/integrations/render.js +25 -10
  96. package/dist/integrations/render.js.map +1 -1
  97. package/dist/integrations/superpowers.d.ts.map +1 -1
  98. package/dist/integrations/superpowers.js +2 -0
  99. package/dist/integrations/superpowers.js.map +1 -1
  100. package/dist/shell/detector.d.ts +63 -5
  101. package/dist/shell/detector.d.ts.map +1 -1
  102. package/dist/shell/detector.js +253 -65
  103. package/dist/shell/detector.js.map +1 -1
  104. package/dist/toolchain/gh.d.ts.map +1 -1
  105. package/dist/toolchain/gh.js +1 -0
  106. package/dist/toolchain/gh.js.map +1 -1
  107. package/dist/toolchain/mise.d.ts +7 -0
  108. package/dist/toolchain/mise.d.ts.map +1 -1
  109. package/dist/toolchain/mise.js +20 -3
  110. package/dist/toolchain/mise.js.map +1 -1
  111. package/dist/toolchain/rtk.d.ts +71 -3
  112. package/dist/toolchain/rtk.d.ts.map +1 -1
  113. package/dist/toolchain/rtk.js +115 -7
  114. package/dist/toolchain/rtk.js.map +1 -1
  115. package/dist/ui/output.d.ts.map +1 -1
  116. package/dist/ui/output.js +10 -2
  117. package/dist/ui/output.js.map +1 -1
  118. package/dist/ui/spinner.d.ts +15 -3
  119. package/dist/ui/spinner.d.ts.map +1 -1
  120. package/dist/ui/spinner.js +52 -4
  121. package/dist/ui/spinner.js.map +1 -1
  122. package/dist/ui/theme.d.ts +27 -1
  123. package/dist/ui/theme.d.ts.map +1 -1
  124. package/dist/ui/theme.js +73 -8
  125. package/dist/ui/theme.js.map +1 -1
  126. package/dist/utils/fs-retry.d.ts +16 -0
  127. package/dist/utils/fs-retry.d.ts.map +1 -1
  128. package/dist/utils/fs-retry.js +28 -0
  129. package/dist/utils/fs-retry.js.map +1 -1
  130. package/package.json +6 -5
@@ -7,6 +7,7 @@ import * as path from 'path';
7
7
  import * as winPath from 'path/win32';
8
8
  import * as child_process from 'child_process';
9
9
  import { AGENT_KEYS } from '../config/schema.js';
10
+ import { homeDir } from '../adapters/agent-dirs.js';
10
11
  import { getShimsDirValue, pathContainsDir, removeShimsDirValue } from '../toolchain/mise.js';
11
12
  import { writeFileWithRetry } from '../utils/fs-retry.js';
12
13
  import { clearShellFixState, mergeShellFixEntries, readShellFixState, shellFixStatePath, writeShellFixState, } from './shell-fix-state.js';
@@ -28,7 +29,7 @@ export const POSIX_UTILITIES = [
28
29
  'mv',
29
30
  'less',
30
31
  ];
31
- // Well-known paths for Git Bash on Windows
32
+ // Well-known machine-wide paths for Git Bash on Windows
32
33
  export const GIT_BASH_PATHS = [
33
34
  'C:\\Program Files\\Git\\bin',
34
35
  'C:\\Program Files\\Git\\usr\\bin',
@@ -36,9 +37,67 @@ export const GIT_BASH_PATHS = [
36
37
  'C:\\Program Files (x86)\\Git\\usr\\bin',
37
38
  ];
38
39
  /**
39
- * Detect the current shell environment
40
+ * Candidate Git Bash directories: the well-known machine-wide paths plus
41
+ * per-user (`%LOCALAPPDATA%\Programs\Git`, the default for a non-admin Git
42
+ * for Windows install) and scoop install locations, which vary per machine
43
+ * and so can't be hardcoded in GIT_BASH_PATHS.
40
44
  */
41
- export function detectShell() {
45
+ export function gitBashCandidatePaths(env = process.env) {
46
+ const candidates = [...GIT_BASH_PATHS];
47
+ const localAppData = env.LOCALAPPDATA;
48
+ if (localAppData) {
49
+ candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin'), path.join(localAppData, 'Programs', 'Git', 'usr', 'bin'));
50
+ }
51
+ const scoopRoot = env.SCOOP || (env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop') : undefined);
52
+ if (scoopRoot) {
53
+ candidates.push(path.join(scoopRoot, 'apps', 'git', 'current', 'bin'), path.join(scoopRoot, 'apps', 'git', 'current', 'usr', 'bin'));
54
+ }
55
+ return candidates;
56
+ }
57
+ /**
58
+ * Fall back to searching PATH for git.exe when none of the well-known
59
+ * install locations exist — covers custom install directories the
60
+ * candidate list can't anticipate. Derives the Git Bash root from git.exe's
61
+ * location (`<root>\cmd\git.exe` or `<root>\bin\git.exe`) and checks that
62
+ * root's `bin` and `usr\bin` directories.
63
+ */
64
+ export function findGitBashOnPath(spawnSync = child_process.spawnSync) {
65
+ try {
66
+ const where = spawnSync('cmd.exe', ['/c', 'where', 'git'], {
67
+ encoding: 'utf8',
68
+ timeout: 5000,
69
+ });
70
+ if (where.status !== 0 || !where.stdout) {
71
+ return undefined;
72
+ }
73
+ const gitExe = where.stdout
74
+ .split(/\r?\n/)
75
+ .map((line) => line.trim())
76
+ .find((line) => line.length > 0);
77
+ if (!gitExe) {
78
+ return undefined;
79
+ }
80
+ const root = path.dirname(path.dirname(gitExe));
81
+ for (const sub of ['bin', path.join('usr', 'bin')]) {
82
+ const candidate = path.join(root, sub);
83
+ if (fs.existsSync(candidate)) {
84
+ return candidate;
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ /**
94
+ * Detect the current shell environment.
95
+ *
96
+ * `spawnSync` is injectable (defaults to the real `child_process.spawnSync`)
97
+ * purely so tests can observe — and assert nothing installs anything through
98
+ * — the one remaining subprocess call this function makes directly.
99
+ */
100
+ export function detectShell(spawnSync = child_process.spawnSync) {
42
101
  const isWindows = process.platform === 'win32';
43
102
  const isMacOS = process.platform === 'darwin';
44
103
  const currentShell = determineCurrentShell();
@@ -58,7 +117,7 @@ export function detectShell() {
58
117
  // Find Git Bash path (used by the Tier 0 fix even when the current shell
59
118
  // is PowerShell/cmd, so agents get pointed at a real POSIX shell).
60
119
  // Use path.resolve() to handle paths with spaces properly.
61
- for (const gitPath of GIT_BASH_PATHS) {
120
+ for (const gitPath of gitBashCandidatePaths()) {
62
121
  try {
63
122
  const resolvedPath = path.resolve(gitPath);
64
123
  if (fs.existsSync(resolvedPath)) {
@@ -70,31 +129,36 @@ export function detectShell() {
70
129
  // Ignore
71
130
  }
72
131
  }
132
+ // None of the well-known install locations exist: search PATH itself,
133
+ // which covers custom install directories.
134
+ if (!gitBashPath) {
135
+ gitBashPath = findGitBashOnPath(spawnSync);
136
+ }
73
137
  }
74
138
  else if (isMacOS) {
75
- // On macOS, check for GNU coreutils
139
+ // On macOS, check for GNU coreutils. detectShell() must stay read-only —
140
+ // status/doctor call it too, and both are documented as never changing
141
+ // the machine — so it only ever detects, never installs. Installing is
142
+ // fixShellConfiguration()'s job (the explicit, reported Tier 0 apply
143
+ // step), which calls installGNUCoreutilsMacOS() itself when needed.
76
144
  isPosixCompatible = checkGNUCoreutils();
77
- // If GNU coreutils are not available, we may need to install them
78
- if (!isPosixCompatible) {
79
- gnubinPath = installGNUCoreutilsMacOS();
80
- if (gnubinPath) {
81
- isPosixCompatible = true;
82
- }
83
- }
84
- else {
85
- // Try to get the gnubin path if coreutils are installed
86
- try {
87
- const result = child_process.spawnSync('brew', ['--prefix', 'coreutils'], {
88
- encoding: 'utf-8',
89
- stdio: ['ignore', 'pipe', 'pipe'],
90
- });
145
+ // Read-only probe: report the gnubin path when coreutils happen to
146
+ // already be installed via Homebrew; do nothing (and no error) otherwise.
147
+ try {
148
+ const result = spawnSync('brew', ['--prefix', 'coreutils'], {
149
+ encoding: 'utf-8',
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ timeout: 10000,
152
+ });
153
+ if (result.status === 0) {
91
154
  const prefix = result.stdout.trim();
92
- gnubinPath = path.join(prefix, 'libexec', 'gnubin');
93
- }
94
- catch {
95
- // coreutils not installed via Homebrew, or Homebrew not available
155
+ if (prefix)
156
+ gnubinPath = path.join(prefix, 'libexec', 'gnubin');
96
157
  }
97
158
  }
159
+ catch {
160
+ // coreutils not installed via Homebrew, or Homebrew not available
161
+ }
98
162
  }
99
163
  else {
100
164
  // On Linux and other Unix-like systems, check for GNU coreutils
@@ -165,7 +229,7 @@ function checkGitBash() {
165
229
  }
166
230
  // Check PATH for Git Bash directories (spaces-safe, case-folded on win32)
167
231
  const pathEnv = process.env.PATH || '';
168
- for (const gitPath of GIT_BASH_PATHS) {
232
+ for (const gitPath of gitBashCandidatePaths()) {
169
233
  if (pathContainsDir(pathEnv, gitPath)) {
170
234
  return true;
171
235
  }
@@ -192,11 +256,13 @@ export function checkGNUCoreutils() {
192
256
  const grepResult = child_process.spawnSync('sh', ['-c', 'command -v grep'], {
193
257
  encoding: 'utf-8',
194
258
  stdio: ['ignore', 'pipe', 'pipe'],
259
+ timeout: 5000,
195
260
  });
196
261
  const grepPath = grepResult.stdout.trim();
197
262
  if (grepPath) {
198
263
  const grepCheck = child_process.spawnSync(grepPath, ['-P', '--version'], {
199
264
  stdio: 'ignore',
265
+ timeout: 5000,
200
266
  });
201
267
  if (grepCheck.status === 0)
202
268
  return true;
@@ -205,10 +271,14 @@ export function checkGNUCoreutils() {
205
271
  const sedResult = child_process.spawnSync('sh', ['-c', 'command -v sed'], {
206
272
  encoding: 'utf-8',
207
273
  stdio: ['ignore', 'pipe', 'pipe'],
274
+ timeout: 5000,
208
275
  });
209
276
  const sedPath = sedResult.stdout.trim();
210
277
  if (sedPath) {
211
- const sedCheck = child_process.spawnSync(sedPath, ['--version'], { stdio: 'ignore' });
278
+ const sedCheck = child_process.spawnSync(sedPath, ['--version'], {
279
+ stdio: 'ignore',
280
+ timeout: 5000,
281
+ });
212
282
  if (sedCheck.status === 0)
213
283
  return true;
214
284
  }
@@ -228,7 +298,10 @@ function installGNUCoreutilsMacOS() {
228
298
  }
229
299
  try {
230
300
  // Check if Homebrew is installed
231
- const brewOk = child_process.spawnSync('sh', ['-c', 'command -v brew'], { stdio: 'ignore' });
301
+ const brewOk = child_process.spawnSync('sh', ['-c', 'command -v brew'], {
302
+ stdio: 'ignore',
303
+ timeout: 5000,
304
+ });
232
305
  if (brewOk.status !== 0) {
233
306
  return undefined;
234
307
  }
@@ -238,15 +311,22 @@ function installGNUCoreutilsMacOS() {
238
311
  // Only install when the formula is not already installed
239
312
  const listed = child_process.spawnSync('brew', ['list', '--formula', pkg], {
240
313
  stdio: 'ignore',
314
+ timeout: 10000,
241
315
  });
242
316
  if (listed.status !== 0) {
243
- child_process.spawnSync('brew', ['install', '--quiet', pkg], { stdio: 'ignore' });
317
+ // An actual package install (network + possibly a build), unlike the
318
+ // read-only probes around it — given more room before we give up.
319
+ child_process.spawnSync('brew', ['install', '--quiet', pkg], {
320
+ stdio: 'ignore',
321
+ timeout: 120000,
322
+ });
244
323
  }
245
324
  }
246
325
  // Get the gnubin path from coreutils
247
326
  const result = child_process.spawnSync('brew', ['--prefix', 'coreutils'], {
248
327
  encoding: 'utf-8',
249
328
  stdio: ['ignore', 'pipe', 'pipe'],
329
+ timeout: 10000,
250
330
  });
251
331
  if (result.status !== 0) {
252
332
  return undefined;
@@ -270,11 +350,17 @@ export function checkMissingUtilities(isPosixCompatible) {
270
350
  for (const util of POSIX_UTILITIES) {
271
351
  // `where` reports "not found" via a non-zero exit code (the INFO text goes
272
352
  // to stderr), so checking the exit status is what distinguishes "found".
273
- const where = child_process.spawnSync('cmd.exe', ['/c', 'where', util], { stdio: 'ignore' });
353
+ const where = child_process.spawnSync('cmd.exe', ['/c', 'where', util], {
354
+ stdio: 'ignore',
355
+ timeout: 5000,
356
+ });
274
357
  if (where.status === 0) {
275
358
  continue;
276
359
  }
277
- const which = child_process.spawnSync('sh', ['-c', `command -v ${util}`], { stdio: 'ignore' });
360
+ const which = child_process.spawnSync('sh', ['-c', `command -v ${util}`], {
361
+ stdio: 'ignore',
362
+ timeout: 5000,
363
+ });
278
364
  if (which.status === 0) {
279
365
  continue;
280
366
  }
@@ -333,15 +419,110 @@ export function getShellConfiguration(shellInfo) {
333
419
  };
334
420
  }
335
421
  /**
336
- * Generate PATH addition for Git Bash on Windows
422
+ * The Git for Windows install root for a detected Git Bash directory
423
+ * (`<root>\bin` or `<root>\usr\bin`), or undefined for any other shape.
424
+ * Uses win32 path semantics so it is correct regardless of host OS.
425
+ */
426
+ export function gitInstallRoot(gitBashPath) {
427
+ if (winPath.basename(gitBashPath).toLowerCase() !== 'bin')
428
+ return undefined;
429
+ const parent = winPath.dirname(gitBashPath);
430
+ return winPath.basename(parent).toLowerCase() === 'usr' ? winPath.dirname(parent) : parent;
431
+ }
432
+ /**
433
+ * The PATH entries that expose Git for Windows' GNU tools (sed, grep, awk,
434
+ * xargs, ...) to native Windows shells: `<root>\mingw64\bin;<root>\usr\bin`.
435
+ * Returns '' when the Git root can't be derived from `gitBashPath`.
337
436
  */
338
437
  export function generateGitBashPathAddition(gitBashPath) {
339
- if (process.platform !== 'win32') {
438
+ const root = gitInstallRoot(gitBashPath);
439
+ if (!root)
340
440
  return '';
441
+ return `${winPath.join(root, 'mingw64', 'bin')};${winPath.join(root, 'usr', 'bin')}`;
442
+ }
443
+ /**
444
+ * GNU tools agents most often reach for in shell commands. On Windows these
445
+ * only resolve from PowerShell/cmd when Git's `usr\bin` is on PATH, which
446
+ * Git's installer does not do by default.
447
+ */
448
+ export const NATIVE_UNIX_TOOLS = [
449
+ 'grep',
450
+ 'sed',
451
+ 'awk',
452
+ 'find',
453
+ 'xargs',
454
+ 'diff',
455
+ 'head',
456
+ 'tail',
457
+ 'wc',
458
+ ];
459
+ /**
460
+ * Resolve `tool` the way cmd.exe/PowerShell would: first PATH entry holding
461
+ * `tool` + a PATHEXT extension wins. `exists` is injectable for tests.
462
+ */
463
+ export function resolveOnWindowsPath(tool, pathVar, pathExt = '.COM;.EXE;.BAT;.CMD', exists = fs.existsSync) {
464
+ const extensions = pathExt.split(';').filter((ext) => ext.trim() !== '');
465
+ for (const rawDir of pathVar.split(';')) {
466
+ const dir = rawDir.trim().replace(/^"(.*)"$/, '$1');
467
+ if (dir === '')
468
+ continue;
469
+ for (const ext of extensions) {
470
+ const candidate = winPath.join(dir, `${tool}${ext.toLowerCase()}`);
471
+ if (exists(candidate))
472
+ return candidate;
473
+ }
341
474
  }
342
- const binPath = path.join(gitBashPath, '..', 'mingw64', 'bin');
343
- const usrBinPath = path.join(gitBashPath, '..', 'usr', 'bin');
344
- return `${binPath};${usrBinPath}`;
475
+ return undefined;
476
+ }
477
+ const PERSISTENT_PATH_KEYS = [
478
+ 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment',
479
+ 'HKCU\\Environment',
480
+ ];
481
+ /**
482
+ * The persistent machine + user PATH from the registry — what a freshly
483
+ * started PowerShell/cmd (and any agent launched from one) sees, unlike
484
+ * process.env.PATH, which already includes Git's dirs when agentenv itself
485
+ * runs inside Git Bash. `%VAR%` references are expanded from `env`.
486
+ * Returns undefined when neither key could be read.
487
+ */
488
+ export function readPersistentWindowsPath(spawnSync = child_process.spawnSync, env = process.env) {
489
+ const parts = [];
490
+ for (const key of PERSISTENT_PATH_KEYS) {
491
+ try {
492
+ const result = spawnSync('reg', ['query', key, '/v', 'Path'], {
493
+ encoding: 'utf8',
494
+ timeout: 5000,
495
+ });
496
+ if (result.status !== 0 || typeof result.stdout !== 'string')
497
+ continue;
498
+ const match = /^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*)$/im.exec(result.stdout);
499
+ if (match)
500
+ parts.push(match[1].trim());
501
+ }
502
+ catch {
503
+ // Key unreadable; fall through to the other one
504
+ }
505
+ }
506
+ if (parts.length === 0)
507
+ return undefined;
508
+ const lookup = new Map(Object.entries(env).map(([name, value]) => [name.toLowerCase(), value]));
509
+ return parts
510
+ .join(';')
511
+ .replace(/%([^%;]+)%/g, (whole, name) => lookup.get(name.toLowerCase()) ?? whole);
512
+ }
513
+ /**
514
+ * When `shell` is Git's raw MSYS bash (`<root>\usr\bin\bash.exe`) and the
515
+ * `<root>\bin\bash.exe` launcher exists, return the launcher. The raw binary
516
+ * started as a plain `bash -c` gets neither MSYSTEM nor `/usr/bin` on PATH,
517
+ * so sed/grep/awk resolve to nothing; the launcher sets both up.
518
+ */
519
+ export function preferredGitBashLauncher(shell, exists = fs.existsSync) {
520
+ const normalized = shell.replace(/\//g, '\\');
521
+ if (!/\\usr\\bin\\bash\.exe$/i.test(normalized))
522
+ return undefined;
523
+ const root = winPath.dirname(winPath.dirname(winPath.dirname(normalized)));
524
+ const launcher = winPath.join(root, 'bin', 'bash.exe');
525
+ return exists(launcher) ? launcher : undefined;
345
526
  }
346
527
  /**
347
528
  * Generate PATH addition for GNU coreutils on macOS
@@ -360,18 +541,19 @@ function priorField(key, previous, set) {
360
541
  /**
361
542
  * Resolve the full path to bash.exe from a detected Git Bash directory.
362
543
  * Handles both the `usr\bin` form and the plain `bin` form Git for Windows ships.
544
+ * Prefers the `<root>\bin\bash.exe` launcher, which sets MSYSTEM and puts
545
+ * `/usr/bin` + `/mingw64/bin` on PATH; the raw `<root>\usr\bin\bash.exe` does
546
+ * neither for a non-login `bash -c`, so GNU tools go missing. Falls back to
547
+ * the raw binary only when the launcher isn't there.
363
548
  * Uses win32 path semantics so the result is correct regardless of host OS
364
549
  * (the tests exercise this on Linux CI).
365
550
  */
366
- export function bashExecutable(gitBashPath) {
367
- const parent = winPath.dirname(gitBashPath);
368
- if (winPath.basename(gitBashPath).toLowerCase() === 'bin') {
369
- if (winPath.basename(parent).toLowerCase() === 'usr') {
370
- return winPath.join(gitBashPath, 'bash.exe');
371
- }
372
- return winPath.join(parent, 'usr', 'bin', 'bash.exe');
373
- }
374
- return winPath.join(gitBashPath, 'bash.exe');
551
+ export function bashExecutable(gitBashPath, exists = fs.existsSync) {
552
+ const root = gitInstallRoot(gitBashPath);
553
+ if (!root)
554
+ return winPath.join(gitBashPath, 'bash.exe');
555
+ const launcher = winPath.join(root, 'bin', 'bash.exe');
556
+ return exists(launcher) ? launcher : winPath.join(root, 'usr', 'bin', 'bash.exe');
375
557
  }
376
558
  /**
377
559
  * Apply the per-agent Tier 0 shell override for one agent, per
@@ -609,35 +791,33 @@ function recordShellFixState(results, bashExe, statePath) {
609
791
  */
610
792
  export function fixShellConfiguration(_baseDir = '.', agents = [...AGENT_KEYS], complex = process.stdin.isTTY === true, statePath = shellFixStatePath()) {
611
793
  if (process.platform === 'win32') {
794
+ // No early return when agentenv itself runs in Git Bash: the fix targets
795
+ // each agent's own shell config, which doesn't depend on the shell
796
+ // agentenv was launched from (see checkAgentShellConfiguration).
612
797
  const shellInfo = detectShell();
613
- if (shellInfo.isPosixCompatible) {
614
- return {
615
- success: true,
616
- message: 'Shell is already POSIX-compatible',
617
- gitBashPath: shellInfo.gitBashPath,
618
- };
619
- }
620
798
  if (!shellInfo.gitBashPath) {
621
799
  return {
622
800
  success: true,
623
- message: 'Git Bash not found on PATH; install Git for Windows to enable Tier 0 shell fixes',
801
+ message: 'Git Bash not found in common install locations or on PATH; install Git for Windows to enable Tier 0 shell fixes',
624
802
  };
625
803
  }
626
- // Non-TTY/test shell: skip file writes, just report the state
804
+ // Non-TTY/test shell, or tier0.mode = "never": skip file writes, just
805
+ // report the state. (Callers already prefix messages with "Tier 0: "
806
+ // themselves, so this string must not repeat it.)
627
807
  if (!complex) {
628
808
  return {
629
809
  success: true,
630
- message: `Tier 0: checked; would fix ${agents.length} agent shell(s) in an interactive terminal — skipped (non-TTY)`,
810
+ message: `checked; would fix ${agents.length} agent shell(s) — skipped (pass --shell-fix always, or run interactively, to apply)`,
631
811
  gitBashPath: shellInfo.gitBashPath,
632
812
  };
633
813
  }
634
814
  const bashExe = bashExecutable(shellInfo.gitBashPath);
635
- const home = process.env.HOME || process.env.USERPROFILE || '';
815
+ const home = homeDir();
636
816
  const results = agents.map((agent) => applyAgentShellFix(agent, bashExe, home));
637
817
  const written = results.filter((result) => result.action === 'created' || result.action === 'updated');
638
818
  recordShellFixState(written, bashExe, statePath);
639
819
  const message = written.length > 0
640
- ? `Current shell (${shellInfo.currentShell}) is not POSIX-compatible; pointed agents at Git Bash (${bashExe})`
820
+ ? `pointed ${written.length} agent shell(s) at Git Bash (${bashExe})`
641
821
  : `Git Bash detected at ${bashExe}; agent shell config already up to date`;
642
822
  return {
643
823
  success: true,
@@ -692,16 +872,16 @@ export function fixShellConfiguration(_baseDir = '.', agents = [...AGENT_KEYS],
692
872
  * since that's the interactive shell the user is running agentenv from, not
693
873
  * the target agent's configuration.
694
874
  */
695
- export function checkAgentShellConfiguration(agent, home = process.env.HOME || process.env.USERPROFILE || '') {
875
+ export function checkAgentShellConfiguration(agent, home = homeDir(), exists = fs.existsSync) {
696
876
  switch (agent) {
697
877
  case 'claude_code':
698
- return checkClaudeCodeShell(home);
878
+ return checkClaudeCodeShell(home, exists);
699
879
  case 'codex_cli':
700
- return checkCodexShell(home);
880
+ return checkCodexShell(home, exists);
701
881
  case 'copilot':
702
882
  return checkCopilotShell();
703
883
  case 'opencode':
704
- return checkOpenCodeShell(home);
884
+ return checkOpenCodeShell(home, exists);
705
885
  case 'gemini_cli':
706
886
  case 'cursor':
707
887
  case 'windsurf':
@@ -713,11 +893,19 @@ export function checkAgentShellConfiguration(agent, home = process.env.HOME || p
713
893
  function isBashPath(value) {
714
894
  return typeof value === 'string' && value.length > 0;
715
895
  }
896
+ /**
897
+ * A configured shell still needs the Tier 0 fix when it isn't bash at all, or
898
+ * when it is Git's raw MSYS bash and the launcher exists to replace it (see
899
+ * preferredGitBashLauncher) — the same value bashExecutable() would write.
900
+ */
901
+ function shellNeedsFix(shell, exists) {
902
+ return (!shell.toLowerCase().includes('bash') || preferredGitBashLauncher(shell, exists) !== undefined);
903
+ }
716
904
  /**
717
905
  * Check Claude Code shell configuration: reads env.CLAUDE_CODE_GIT_BASH_PATH,
718
906
  * the exact field patchClaudeShellFix writes.
719
907
  */
720
- function checkClaudeCodeShell(home) {
908
+ function checkClaudeCodeShell(home, exists) {
721
909
  try {
722
910
  const settingsPath = path.join(home, '.claude', 'settings.json');
723
911
  if (fs.existsSync(settingsPath)) {
@@ -728,7 +916,7 @@ function checkClaudeCodeShell(home) {
728
916
  return {
729
917
  isConfigured: true,
730
918
  shell: bashPath,
731
- needsFix: !bashPath.toLowerCase().includes('bash'),
919
+ needsFix: shellNeedsFix(bashPath, exists),
732
920
  };
733
921
  }
734
922
  }
@@ -771,7 +959,7 @@ function readTomlWindowsShellPath(content) {
771
959
  * Check Codex CLI shell configuration: reads [windows] shell_path from
772
960
  * config.toml, the exact field patchCodexShellFix writes.
773
961
  */
774
- function checkCodexShell(home) {
962
+ function checkCodexShell(home, exists) {
775
963
  try {
776
964
  const configPath = path.join(home, '.codex', 'config.toml');
777
965
  if (fs.existsSync(configPath)) {
@@ -780,7 +968,7 @@ function checkCodexShell(home) {
780
968
  return {
781
969
  isConfigured: true,
782
970
  shell: shellPath,
783
- needsFix: !shellPath.toLowerCase().includes('bash'),
971
+ needsFix: shellNeedsFix(shellPath, exists),
784
972
  };
785
973
  }
786
974
  }
@@ -802,14 +990,14 @@ function checkCopilotShell() {
802
990
  * Check OpenCode shell configuration: reads `shell`, the exact field
803
991
  * patchOpenCodeShellFix writes.
804
992
  */
805
- function checkOpenCodeShell(home) {
993
+ function checkOpenCodeShell(home, exists) {
806
994
  try {
807
995
  const configPath = path.join(home, '.config', 'opencode', 'opencode.json');
808
996
  if (fs.existsSync(configPath)) {
809
997
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
810
998
  const shell = config.shell;
811
999
  if (isBashPath(shell)) {
812
- return { isConfigured: true, shell, needsFix: !shell.toLowerCase().includes('bash') };
1000
+ return { isConfigured: true, shell, needsFix: shellNeedsFix(shell, exists) };
813
1001
  }
814
1002
  }
815
1003
  return { isConfigured: false, needsFix: true };