@phnx-labs/agents-cli 1.20.33 → 1.20.34

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 (56) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/computer.d.ts +23 -0
  4. package/dist/commands/computer.js +45 -3
  5. package/dist/commands/doctor.d.ts +10 -0
  6. package/dist/commands/doctor.js +49 -0
  7. package/dist/commands/import.js +1 -1
  8. package/dist/commands/rules.js +1 -1
  9. package/dist/commands/secrets-migrate.js +23 -11
  10. package/dist/commands/secrets.d.ts +20 -0
  11. package/dist/commands/secrets.js +53 -1
  12. package/dist/commands/status.d.ts +12 -0
  13. package/dist/commands/status.js +81 -0
  14. package/dist/commands/teams.js +70 -6
  15. package/dist/commands/versions.js +2 -1
  16. package/dist/commands/view.d.ts +39 -0
  17. package/dist/commands/view.js +194 -75
  18. package/dist/index.js +4 -2
  19. package/dist/lib/acp/harnesses.d.ts +1 -1
  20. package/dist/lib/acp/harnesses.js +2 -2
  21. package/dist/lib/agents.d.ts +12 -0
  22. package/dist/lib/agents.js +115 -32
  23. package/dist/lib/browser/chrome.js +20 -0
  24. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  25. package/dist/lib/browser/drivers/ssh.js +18 -3
  26. package/dist/lib/doctor-diff.js +29 -2
  27. package/dist/lib/drift-sync.d.ts +43 -0
  28. package/dist/lib/drift-sync.js +179 -0
  29. package/dist/lib/exec.d.ts +15 -0
  30. package/dist/lib/exec.js +21 -11
  31. package/dist/lib/platform/winpath.d.ts +31 -2
  32. package/dist/lib/platform/winpath.js +133 -24
  33. package/dist/lib/pwsh.d.ts +11 -0
  34. package/dist/lib/pwsh.js +13 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/secrets/agent.d.ts +42 -1
  38. package/dist/lib/secrets/agent.js +89 -11
  39. package/dist/lib/secrets/bundles.js +40 -9
  40. package/dist/lib/secrets/filestore.js +31 -1
  41. package/dist/lib/secrets/index.d.ts +33 -1
  42. package/dist/lib/secrets/index.js +90 -9
  43. package/dist/lib/secrets/windows.d.ts +74 -0
  44. package/dist/lib/secrets/windows.js +440 -0
  45. package/dist/lib/shims.d.ts +20 -0
  46. package/dist/lib/shims.js +53 -20
  47. package/dist/lib/startup/command-registry.d.ts +1 -0
  48. package/dist/lib/startup/command-registry.js +2 -0
  49. package/dist/lib/sync-status.d.ts +102 -0
  50. package/dist/lib/sync-status.js +135 -0
  51. package/dist/lib/teams/agents.d.ts +24 -0
  52. package/dist/lib/teams/agents.js +30 -1
  53. package/dist/lib/types.d.ts +20 -1
  54. package/dist/lib/usage.d.ts +30 -0
  55. package/dist/lib/usage.js +159 -2
  56. package/package.json +1 -1
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Windows secret storage via Windows Credential Manager (wincred).
3
+ *
4
+ * Primary backend: the Credential Manager `advapi32` API (CredReadW /
5
+ * CredWriteW / CredDeleteW / CredEnumerateW), reached through a static
6
+ * PowerShell script that P/Invokes the C# shim below. PowerShell (Windows
7
+ * PowerShell 5.1) ships with every supported Windows, so there is no separate
8
+ * install step. Items are stored as CRED_TYPE_GENERIC with
9
+ * CRED_PERSIST_LOCAL_MACHINE — device-local, matching the biometry-bound model
10
+ * on macOS (src/lib/secrets/index.ts).
11
+ *
12
+ * Zero injection surface: the PS script is a single STATIC constant. All
13
+ * dynamic data rides in the child ENV (target name, list prefix) or STDIN (the
14
+ * secret value) — nothing is string-interpolated into the script. The child is
15
+ * spawned with a spawnSync ARGV ARRAY (`-EncodedCommand <base64>`), never a
16
+ * shell string.
17
+ *
18
+ * Headless fallback: when Credential Manager is unreachable (no logon session —
19
+ * ERROR_NO_SUCH_LOGON_SESSION 1312 — or powershell.exe missing from PATH), we
20
+ * transparently switch to the AES-256-GCM encrypted-file store in
21
+ * ./filestore.ts, exactly like the Linux locked-collection fallback. The
22
+ * decision is cached per process; one stderr line is emitted the first time.
23
+ *
24
+ * Item names are stored VERBATIM as the credential TargetName
25
+ * (`agents-cli.bundles.<name>` / `agents-cli.secrets.<bundle>.<key>` — the
26
+ * scheme shared with the file store, see ./filestore.ts) so `list` returns item
27
+ * names directly.
28
+ */
29
+ import { spawnSync } from 'child_process';
30
+ import { encodePwshBase64 } from '../pwsh.js';
31
+ import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
32
+ // Re-exported so importers (and tests) can keep reaching these via './windows.js'.
33
+ export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
34
+ const POWERSHELL = 'powershell.exe';
35
+ /**
36
+ * CRED_MAX_CREDENTIAL_BLOB_SIZE — Credential Manager rejects a generic
37
+ * credential blob larger than 2560 bytes with an opaque CredWrite failure. We
38
+ * guard against it in `set` with a clear message. Only pathologically large
39
+ * bundle metadata could hit this; such an item should live in a file-backed
40
+ * bundle (AGENTS_SECRETS_PASSPHRASE) instead.
41
+ */
42
+ export const CRED_MAX_CREDENTIAL_BLOB_SIZE = 2560;
43
+ /**
44
+ * The static PowerShell driver. Dispatches on $env:AGENTS_CRED_OP; reads the
45
+ * target name from $env:AGENTS_CRED_TARGET, the list prefix from
46
+ * $env:AGENTS_CRED_PREFIX, and (for `set`) the raw secret value from stdin.
47
+ * Nothing dynamic is interpolated into this string.
48
+ *
49
+ * Exit codes: 0 = success, 3 = clean "not found", 1 = error (message on stderr,
50
+ * carrying the Win32 code so the Node side can detect an unavailable store).
51
+ * `get` emits the blob as base64 (dodges PowerShell CRLF/encoding corruption);
52
+ * `list` prints one target name per line.
53
+ */
54
+ const PS_SCRIPT = `
55
+ $ErrorActionPreference = 'Stop'
56
+ Add-Type -TypeDefinition @'
57
+ using System;
58
+ using System.Runtime.InteropServices;
59
+ using System.Collections.Generic;
60
+
61
+ public static class AgentsCred {
62
+ const uint CRED_TYPE_GENERIC = 1;
63
+ const uint CRED_PERSIST_LOCAL_MACHINE = 2;
64
+ const int ERROR_NOT_FOUND = 1168;
65
+
66
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
67
+ struct CREDENTIAL {
68
+ public uint Flags;
69
+ public uint Type;
70
+ public IntPtr TargetName;
71
+ public IntPtr Comment;
72
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
73
+ public uint CredentialBlobSize;
74
+ public IntPtr CredentialBlob;
75
+ public uint Persist;
76
+ public uint AttributeCount;
77
+ public IntPtr Attributes;
78
+ public IntPtr TargetAlias;
79
+ public IntPtr UserName;
80
+ }
81
+
82
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
83
+ static extern bool CredReadW(string target, uint type, uint flags, out IntPtr cred);
84
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
85
+ static extern bool CredWriteW(ref CREDENTIAL cred, uint flags);
86
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
87
+ static extern bool CredDeleteW(string target, uint type, uint flags);
88
+ [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
89
+ static extern bool CredEnumerateW(string filter, uint flags, out uint count, out IntPtr creds);
90
+ [DllImport("advapi32.dll", SetLastError = false)]
91
+ static extern void CredFree(IntPtr buffer);
92
+
93
+ public static bool Has(string target) {
94
+ IntPtr p;
95
+ if (CredReadW(target, CRED_TYPE_GENERIC, 0, out p)) { CredFree(p); return true; }
96
+ int err = Marshal.GetLastWin32Error();
97
+ if (err == ERROR_NOT_FOUND) return false;
98
+ throw new Exception("CredMan error " + err);
99
+ }
100
+
101
+ public static byte[] Get(string target) {
102
+ IntPtr p;
103
+ if (!CredReadW(target, CRED_TYPE_GENERIC, 0, out p)) {
104
+ int err = Marshal.GetLastWin32Error();
105
+ if (err == ERROR_NOT_FOUND) throw new Exception("NOTFOUND");
106
+ throw new Exception("CredMan error " + err);
107
+ }
108
+ try {
109
+ CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));
110
+ byte[] blob = new byte[cred.CredentialBlobSize];
111
+ if (cred.CredentialBlobSize > 0)
112
+ Marshal.Copy(cred.CredentialBlob, blob, 0, (int)cred.CredentialBlobSize);
113
+ return blob;
114
+ } finally { CredFree(p); }
115
+ }
116
+
117
+ public static void Set(string target, byte[] blob) {
118
+ CREDENTIAL cred = new CREDENTIAL();
119
+ cred.Type = CRED_TYPE_GENERIC;
120
+ cred.TargetName = Marshal.StringToCoTaskMemUni(target);
121
+ cred.CredentialBlobSize = (uint)blob.Length;
122
+ cred.CredentialBlob = (blob.Length > 0) ? Marshal.AllocCoTaskMem(blob.Length) : IntPtr.Zero;
123
+ if (blob.Length > 0) Marshal.Copy(blob, 0, cred.CredentialBlob, blob.Length);
124
+ cred.Persist = CRED_PERSIST_LOCAL_MACHINE;
125
+ cred.UserName = Marshal.StringToCoTaskMemUni(Environment.UserName);
126
+ try {
127
+ if (!CredWriteW(ref cred, 0)) {
128
+ int err = Marshal.GetLastWin32Error();
129
+ throw new Exception("CredMan error " + err);
130
+ }
131
+ } finally {
132
+ Marshal.FreeCoTaskMem(cred.TargetName);
133
+ if (cred.CredentialBlob != IntPtr.Zero) Marshal.FreeCoTaskMem(cred.CredentialBlob);
134
+ Marshal.FreeCoTaskMem(cred.UserName);
135
+ }
136
+ }
137
+
138
+ public static bool Delete(string target) {
139
+ if (CredDeleteW(target, CRED_TYPE_GENERIC, 0)) return true;
140
+ int err = Marshal.GetLastWin32Error();
141
+ if (err == ERROR_NOT_FOUND) return false;
142
+ throw new Exception("CredMan error " + err);
143
+ }
144
+
145
+ public static List<string> List(string filter) {
146
+ uint count;
147
+ IntPtr credsPtr;
148
+ List<string> results = new List<string>();
149
+ string f = string.IsNullOrEmpty(filter) ? null : filter;
150
+ if (!CredEnumerateW(f, 0, out count, out credsPtr)) {
151
+ int err = Marshal.GetLastWin32Error();
152
+ if (err == ERROR_NOT_FOUND) return results;
153
+ throw new Exception("CredMan error " + err);
154
+ }
155
+ try {
156
+ for (int i = 0; i < count; i++) {
157
+ IntPtr credPtr = Marshal.ReadIntPtr(credsPtr, i * IntPtr.Size);
158
+ CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(credPtr, typeof(CREDENTIAL));
159
+ if (cred.TargetName != IntPtr.Zero)
160
+ results.Add(Marshal.PtrToStringUni(cred.TargetName));
161
+ }
162
+ } finally { CredFree(credsPtr); }
163
+ return results;
164
+ }
165
+ }
166
+ '@
167
+
168
+ try {
169
+ $op = $env:AGENTS_CRED_OP
170
+ $target = $env:AGENTS_CRED_TARGET
171
+ switch ($op) {
172
+ 'has' {
173
+ if ([AgentsCred]::Has($target)) { exit 0 } else { exit 3 }
174
+ }
175
+ 'get' {
176
+ $blob = [AgentsCred]::Get($target)
177
+ [Console]::Out.Write([Convert]::ToBase64String($blob))
178
+ exit 0
179
+ }
180
+ 'set' {
181
+ $value = [Console]::In.ReadToEnd()
182
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($value)
183
+ [AgentsCred]::Set($target, $bytes)
184
+ exit 0
185
+ }
186
+ 'delete' {
187
+ if ([AgentsCred]::Delete($target)) { exit 0 } else { exit 3 }
188
+ }
189
+ 'list' {
190
+ $prefix = $env:AGENTS_CRED_PREFIX
191
+ $filter = ''
192
+ if (-not [string]::IsNullOrEmpty($prefix)) { $filter = $prefix + '*' }
193
+ foreach ($n in [AgentsCred]::List($filter)) { [Console]::Out.WriteLine($n) }
194
+ exit 0
195
+ }
196
+ default {
197
+ [Console]::Error.Write('unknown op: ' + $op)
198
+ exit 1
199
+ }
200
+ }
201
+ } catch {
202
+ $m = $_.Exception.Message
203
+ if ($m -match 'NOTFOUND') { exit 3 }
204
+ [Console]::Error.Write($m)
205
+ exit 1
206
+ }
207
+ `;
208
+ const ENCODED_SCRIPT = encodePwshBase64(PS_SCRIPT);
209
+ function runCred(op, opts) {
210
+ const env = { ...process.env, AGENTS_CRED_OP: op };
211
+ if (opts.target !== undefined)
212
+ env.AGENTS_CRED_TARGET = opts.target;
213
+ if (opts.prefix !== undefined)
214
+ env.AGENTS_CRED_PREFIX = opts.prefix;
215
+ // ARGV ARRAY — never a shell string. -EncodedCommand rides base64 of the
216
+ // UTF-16LE script with zero escaping hazards.
217
+ const result = spawnSync(POWERSHELL, ['-NoProfile', '-NonInteractive', '-EncodedCommand', ENCODED_SCRIPT], {
218
+ env,
219
+ input: opts.input,
220
+ stdio: ['pipe', 'pipe', 'pipe'],
221
+ maxBuffer: 16 * 1024 * 1024,
222
+ });
223
+ return {
224
+ status: result.status,
225
+ stdout: result.stdout?.toString() ?? '',
226
+ stderr: result.stderr?.toString() ?? '',
227
+ spawnError: !!result.error,
228
+ };
229
+ }
230
+ // ---------- powershell availability ----------
231
+ function powershellAvailable() {
232
+ const result = spawnSync(POWERSHELL, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], {
233
+ stdio: ['ignore', 'ignore', 'ignore'],
234
+ });
235
+ return !result.error && result.status === 0;
236
+ }
237
+ let checkedAvailability = false;
238
+ let isAvailable = false;
239
+ // ---------- file fallback state ----------
240
+ let useFileFallback = false;
241
+ let warnedFallback = false;
242
+ function activateFileFallback() {
243
+ if (useFileFallback)
244
+ return;
245
+ useFileFallback = true;
246
+ if (!warnedFallback) {
247
+ warnedFallback = true;
248
+ process.stderr.write(`[agents] Windows Credential Manager unavailable, using file-based store at ${fileDir()}\n`);
249
+ }
250
+ }
251
+ /**
252
+ * Credential Manager is "unavailable" (as opposed to a plain not-found or a
253
+ * malformed item) when there's no logon session to hold credentials
254
+ * (ERROR_NO_SUCH_LOGON_SESSION 1312 — common under a service account / SSH
255
+ * session with no interactive logon) or powershell.exe can't be spawned at all.
256
+ * Those cases route to the encrypted-file fallback, exactly like the Linux
257
+ * locked-collection error.
258
+ */
259
+ function isCredManUnavailableError(r) {
260
+ if (r.spawnError)
261
+ return true; // powershell.exe not found
262
+ return /\b1312\b/.test(r.stderr) || /NO_SUCH_LOGON_SESSION/i.test(r.stderr);
263
+ }
264
+ /**
265
+ * Decide which backend a given op should use. Activates the file fallback if a
266
+ * previous run already committed to it (encrypted items on disk), or if
267
+ * powershell.exe is missing and a passphrase source exists (explicit
268
+ * AGENTS_SECRETS_PASSPHRASE, a provisioned machine-local key, or a headless
269
+ * context). The disk-items check makes the fallback persistent across the many
270
+ * short-lived `agents secrets ...` Node processes.
271
+ */
272
+ function preflight() {
273
+ if (useFileFallback)
274
+ return 'file';
275
+ if (fileStoreHasItems()) {
276
+ activateFileFallback();
277
+ return 'file';
278
+ }
279
+ if (!checkedAvailability) {
280
+ isAvailable = powershellAvailable();
281
+ checkedAvailability = true;
282
+ }
283
+ if (!isAvailable) {
284
+ if (process.env.AGENTS_SECRETS_PASSPHRASE || machinePassphraseExists() || !process.stdin.isTTY) {
285
+ activateFileFallback();
286
+ return 'file';
287
+ }
288
+ throw new Error('powershell.exe not found on PATH; cannot reach Windows Credential Manager.\n' +
289
+ 'Set AGENTS_SECRETS_PASSPHRASE to use the encrypted-file fallback.');
290
+ }
291
+ return 'credman';
292
+ }
293
+ /**
294
+ * True when secret operations currently route to the encrypted-file store
295
+ * instead of Windows Credential Manager. Mirrors linux.ts:usesFileFallback so
296
+ * `listBundles()` doesn't double-count file-backed bundles under the fallback.
297
+ */
298
+ export function usesFileFallback() {
299
+ try {
300
+ return preflight() === 'file';
301
+ }
302
+ catch {
303
+ return false;
304
+ }
305
+ }
306
+ // ---------- Credential Manager ops with fallback ----------
307
+ export function hasCredManToken(item) {
308
+ if (preflight() === 'file')
309
+ return fileStore.has(item);
310
+ const r = runCred('has', { target: item });
311
+ if (r.status === 0)
312
+ return true;
313
+ if (r.status === 3)
314
+ return false;
315
+ if (isCredManUnavailableError(r)) {
316
+ activateFileFallback();
317
+ return fileStore.has(item);
318
+ }
319
+ return false;
320
+ }
321
+ export function getCredManToken(item) {
322
+ if (preflight() === 'file')
323
+ return fileStore.get(item);
324
+ const r = runCred('get', { target: item });
325
+ if (r.status === 0) {
326
+ // stdout is base64 of the raw UTF-8 blob (dodges PowerShell encoding corruption).
327
+ return Buffer.from(r.stdout.trim(), 'base64').toString('utf8');
328
+ }
329
+ if (r.status === 3)
330
+ throw new Error(`Secret '${item}' not found in Credential Manager.`);
331
+ if (isCredManUnavailableError(r)) {
332
+ activateFileFallback();
333
+ return fileStore.get(item);
334
+ }
335
+ throw new Error(`Failed to read secret '${item}': ${r.stderr.trim() || 'unknown error'}`);
336
+ }
337
+ export function setCredManToken(item, value) {
338
+ if (!value || !value.trim())
339
+ throw new Error('Secret value is empty.');
340
+ if (preflight() === 'file') {
341
+ fileStore.set(item, value);
342
+ return;
343
+ }
344
+ const byteLen = Buffer.byteLength(value, 'utf8');
345
+ if (byteLen > CRED_MAX_CREDENTIAL_BLOB_SIZE) {
346
+ throw new Error(`Secret '${item}' is ${byteLen} bytes, exceeding the Windows Credential Manager limit of ` +
347
+ `${CRED_MAX_CREDENTIAL_BLOB_SIZE} bytes (CRED_MAX_CREDENTIAL_BLOB_SIZE). ` +
348
+ 'Use a file-backed bundle (set AGENTS_SECRETS_PASSPHRASE) for values this large.');
349
+ }
350
+ const r = runCred('set', { target: item, input: value });
351
+ if (r.status === 0)
352
+ return;
353
+ if (isCredManUnavailableError(r)) {
354
+ activateFileFallback();
355
+ fileStore.set(item, value);
356
+ return;
357
+ }
358
+ throw new Error(`Failed to store secret '${item}': ${r.stderr.trim() || 'unknown error'}`);
359
+ }
360
+ export function deleteCredManToken(item) {
361
+ if (preflight() === 'file')
362
+ return fileStore.delete(item);
363
+ const r = runCred('delete', { target: item });
364
+ if (r.status === 0)
365
+ return true;
366
+ if (r.status === 3)
367
+ return false;
368
+ if (isCredManUnavailableError(r)) {
369
+ activateFileFallback();
370
+ return fileStore.delete(item);
371
+ }
372
+ return false;
373
+ }
374
+ export function listCredManItems(prefix) {
375
+ if (preflight() === 'file')
376
+ return fileStore.list(prefix);
377
+ const r = runCred('list', { prefix });
378
+ if (r.status === 0)
379
+ return parseWindowsCredList(r.stdout, prefix);
380
+ if (isCredManUnavailableError(r)) {
381
+ activateFileFallback();
382
+ return fileStore.list(prefix);
383
+ }
384
+ return [];
385
+ }
386
+ /**
387
+ * Parse the target names printed by the `list` op (one per line), keeping only
388
+ * those starting with `prefix` and deduping. Same contract as
389
+ * parseSecretToolItems (linux.ts). Exported for tests.
390
+ */
391
+ export function parseWindowsCredList(output, prefix) {
392
+ const items = output
393
+ .split(/\r?\n/)
394
+ .map((s) => s.trim())
395
+ .filter((s) => s.length > 0)
396
+ .filter((s) => s.startsWith(prefix));
397
+ return [...new Set(items)]; // dedupe
398
+ }
399
+ /**
400
+ * KeychainBackend implementation for Windows. Routes through Windows Credential
401
+ * Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
402
+ * when the credential store is unreachable.
403
+ */
404
+ export const windowsBackend = {
405
+ has(item) {
406
+ return hasCredManToken(item);
407
+ },
408
+ get(item) {
409
+ return getCredManToken(item);
410
+ },
411
+ set(item, value) {
412
+ setCredManToken(item, value);
413
+ },
414
+ delete(item) {
415
+ return deleteCredManToken(item);
416
+ },
417
+ list(prefix) {
418
+ return listCredManItems(prefix);
419
+ },
420
+ };
421
+ /**
422
+ * Test-only: reset module state so independent test cases don't bleed
423
+ * availability / fallback decisions across each other. Pass `forceAvailable` to
424
+ * pin the powershell-availability probe (skips the real spawn); pass `fileDir`
425
+ * to redirect the encrypted-file store to a temp dir. File-store state lives in
426
+ * ./filestore.ts and is reset there.
427
+ */
428
+ export function _resetForTest(opts = {}) {
429
+ _resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
430
+ useFileFallback = opts.forceFileFallback ?? false;
431
+ warnedFallback = false;
432
+ if (opts.forceAvailable === undefined || opts.forceAvailable === null) {
433
+ checkedAvailability = false;
434
+ isAvailable = false;
435
+ }
436
+ else {
437
+ checkedAvailability = true;
438
+ isAvailable = opts.forceAvailable;
439
+ }
440
+ }
@@ -83,6 +83,18 @@ export declare const SHIM_SCHEMA_VERSION = 19;
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
84
84
  */
85
85
  export declare function generateShimScript(agent: AgentId): string;
86
+ /**
87
+ * Which shim files to materialize for a platform. Pure — testable on any host.
88
+ *
89
+ * POSIX writes the extensionless `#!/bin/bash` shim — the file PATH resolution
90
+ * execs. Windows writes only the `.cmd` companion: PATHEXT makes it the runnable
91
+ * form, and the bash file (mode 0o755 is a no-op there) is never executed — so
92
+ * emitting it is dead weight that only ever confuses `where agents`.
93
+ */
94
+ export declare function shimTargetsFor(platform: NodeJS.Platform): {
95
+ bash: boolean;
96
+ cmd: boolean;
97
+ };
86
98
  /**
87
99
  * Create a shim for an agent.
88
100
  */
@@ -219,6 +231,14 @@ export declare function getConfigSymlinkVersion(agent: AgentId): string | null;
219
231
  /**
220
232
  * Check if shim exists for an agent.
221
233
  */
234
+ /**
235
+ * The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
236
+ * write-side source of truth) so the exists/remove/version checks can never
237
+ * drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
238
+ * file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
239
+ * host.
240
+ */
241
+ export declare function onDiskShimFile(cliCommand: string, platform: NodeJS.Platform): string;
222
242
  export declare function shimExists(agent: AgentId): boolean;
223
243
  /**
224
244
  * Regenerate the shim if it's missing or outdated. Returns a status describing
package/dist/lib/shims.js CHANGED
@@ -528,6 +528,19 @@ fi
528
528
  exec "$BINARY"${launchArgs} "$@"
529
529
  `;
530
530
  }
531
+ /**
532
+ * Which shim files to materialize for a platform. Pure — testable on any host.
533
+ *
534
+ * POSIX writes the extensionless `#!/bin/bash` shim — the file PATH resolution
535
+ * execs. Windows writes only the `.cmd` companion: PATHEXT makes it the runnable
536
+ * form, and the bash file (mode 0o755 is a no-op there) is never executed — so
537
+ * emitting it is dead weight that only ever confuses `where agents`.
538
+ */
539
+ export function shimTargetsFor(platform) {
540
+ if (platform === 'win32')
541
+ return { bash: false, cmd: true };
542
+ return { bash: true, cmd: false };
543
+ }
531
544
  /**
532
545
  * Create a shim for an agent.
533
546
  */
@@ -536,12 +549,15 @@ export function createShim(agent) {
536
549
  const shimsDir = getShimsDir();
537
550
  const agentConfig = AGENTS[agent];
538
551
  const shimPath = path.join(shimsDir, agentConfig.cliCommand);
539
- const script = generateShimScript(agent);
540
- fs.writeFileSync(shimPath, script, { mode: 0o755 });
541
- // Windows can't execute the bash shim directly. Drop a `.cmd` companion next
542
- // to it that delegates to the node-side transparent resolver (`agents __shim`),
543
- // so the version resolution stays single-sourced instead of reimplemented in batch.
544
- if (IS_WINDOWS) {
552
+ const targets = shimTargetsFor(process.platform);
553
+ if (targets.bash) {
554
+ fs.writeFileSync(shimPath, generateShimScript(agent), { mode: 0o755 });
555
+ }
556
+ // Windows can't execute the bash shim directly. Drop a `.cmd` companion which
557
+ // delegates to the node-side transparent resolver (`agents __shim`) so version
558
+ // resolution stays single-sourced instead of reimplemented in batch — and skip
559
+ // the vestigial bash file entirely.
560
+ if (targets.cmd) {
545
561
  writeWindowsCmdShim(shimPath + '.cmd', agentConfig.cliCommand);
546
562
  }
547
563
  return shimPath;
@@ -557,6 +573,7 @@ function writeWindowsCmdShim(cmdPath, spec) {
557
573
  const indexJs = getAgentsBinForGeneratedShim();
558
574
  const content = `@echo off\r\n` +
559
575
  `rem Auto-generated by agents-cli - do not edit\r\n` +
576
+ `rem ${SHIM_VERSION_MARKER} ${SHIM_SCHEMA_VERSION}\r\n` +
560
577
  `node "${indexJs}" __shim ${spec} %*\r\n`;
561
578
  fs.writeFileSync(cmdPath, content);
562
579
  }
@@ -567,17 +584,18 @@ export function removeShim(agent) {
567
584
  const shimsDir = getShimsDir();
568
585
  const agentConfig = AGENTS[agent];
569
586
  const shimPath = path.join(shimsDir, agentConfig.cliCommand);
570
- if (fs.existsSync(shimPath)) {
571
- fs.unlinkSync(shimPath);
572
- if (IS_WINDOWS) {
573
- try {
574
- fs.unlinkSync(shimPath + '.cmd');
575
- }
576
- catch { }
587
+ // Remove whichever companions exist: the extensionless script (POSIX, or a
588
+ // legacy Windows install that wrote it) AND the `.cmd` (Windows). Keying only
589
+ // off the extensionless path would orphan the `.cmd` on Windows, where
590
+ // createShim now writes only the `.cmd`.
591
+ let removed = false;
592
+ for (const p of [shimPath, shimPath + '.cmd']) {
593
+ if (fs.existsSync(p)) {
594
+ fs.unlinkSync(p);
595
+ removed = true;
577
596
  }
578
- return true;
579
597
  }
580
- return false;
598
+ return removed;
581
599
  }
582
600
  /**
583
601
  * Current versioned-alias schema. Bump whenever `generateVersionedAliasScript`
@@ -1376,11 +1394,26 @@ async function copyDirContents(src, dest, strategy = 'keep-dest', context) {
1376
1394
  /**
1377
1395
  * Check if shim exists for an agent.
1378
1396
  */
1397
+ /**
1398
+ * The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
1399
+ * write-side source of truth) so the exists/remove/version checks can never
1400
+ * drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
1401
+ * file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
1402
+ * host.
1403
+ */
1404
+ export function onDiskShimFile(cliCommand, platform) {
1405
+ return shimTargetsFor(platform).cmd ? `${cliCommand}.cmd` : cliCommand;
1406
+ }
1407
+ /**
1408
+ * The actual on-disk shim path for the current platform. This is what
1409
+ * exists/version checks must stat — `getShimPath` returns the logical
1410
+ * (extensionless) launch path, which is not always a real file on Windows.
1411
+ */
1412
+ function onDiskShimPath(agent) {
1413
+ return path.join(getShimsDir(), onDiskShimFile(AGENTS[agent].cliCommand, process.platform));
1414
+ }
1379
1415
  export function shimExists(agent) {
1380
- const shimsDir = getShimsDir();
1381
- const agentConfig = AGENTS[agent];
1382
- const shimPath = path.join(shimsDir, agentConfig.cliCommand);
1383
- return fs.existsSync(shimPath);
1416
+ return fs.existsSync(onDiskShimPath(agent));
1384
1417
  }
1385
1418
  /**
1386
1419
  * Read the schema version embedded in an existing on-disk shim. Returns
@@ -1390,7 +1423,7 @@ function readShimSchemaVersion(agent) {
1390
1423
  if (!shimExists(agent))
1391
1424
  return null;
1392
1425
  try {
1393
- const content = fs.readFileSync(getShimPath(agent), 'utf8');
1426
+ const content = fs.readFileSync(onDiskShimPath(agent), 'utf8');
1394
1427
  // Look at the first ~10 lines only — the marker lives in the header.
1395
1428
  const header = content.split('\n', 10).join('\n');
1396
1429
  const match = header.match(new RegExp(SHIM_VERSION_MARKER + '\\s*(\\d+)'));
@@ -49,6 +49,7 @@ export declare const loadPrune: ModuleLoader;
49
49
  export declare const loadTrash: ModuleLoader;
50
50
  export declare const loadRestore: ModuleLoader;
51
51
  export declare const loadDoctor: ModuleLoader;
52
+ export declare const loadStatus: ModuleLoader;
52
53
  export declare const loadProfiles: ModuleLoader;
53
54
  export declare const loadSecrets: ModuleLoader;
54
55
  export declare const loadWallet: ModuleLoader;
@@ -27,6 +27,7 @@ export const loadPrune = async () => (await import('../../commands/prune.js')).r
27
27
  export const loadTrash = async () => (await import('../../commands/trash.js')).registerTrashCommands;
28
28
  export const loadRestore = async () => (await import('../../commands/trash.js')).registerRestoreCommand;
29
29
  export const loadDoctor = async () => (await import('../../commands/doctor.js')).registerDoctorCommand;
30
+ export const loadStatus = async () => (await import('../../commands/status.js')).registerStatusCommand;
30
31
  export const loadProfiles = async () => (await import('../../commands/profiles.js')).registerProfilesCommands;
31
32
  export const loadSecrets = async () => (await import('../../commands/secrets.js')).registerSecretsCommands;
32
33
  export const loadWallet = async () => (await import('../../commands/wallet.js')).registerWalletCommands;
@@ -110,6 +111,7 @@ export const COMMAND_LOADERS = {
110
111
  trash: [loadTrash],
111
112
  restore: [loadRestore],
112
113
  doctor: [loadDoctor],
114
+ status: [loadStatus],
113
115
  profiles: [loadProfiles],
114
116
  secrets: [loadSecrets],
115
117
  wallet: [loadWallet],