@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.34
6
+
7
+ **Test suite runs remotely on a crabbox VM (#525, #540)**
8
+
9
+ - `scripts/release.sh`'s test gate now runs `bun install && bun run build && bun run test` on a leased crabbox VM via `scripts/sandbox.sh` instead of freezing the local machine, matching CI's Build→Test order (crabbox's sync honors `.gitignore`, so the gitignored `dist/` is built on the box). A new `bun run test:remote` offloads the suite the same way for local dev. Publishing still happens locally — only the signed macOS keychain helper can be produced and notarized here, and crabbox boxes are Linux. Source: `scripts/sandbox.sh`, `scripts/release.sh`, `package.json`.
10
+ - `scripts/sandbox.sh` box acquisition is now robust: secrets load via `agents secrets export --plaintext` (the bare form now hard-errors), a missing `.crabbox.yaml` no longer aborts the script under `set -e`, and the agents-cli/claude install is gated to PR mode so test-mode runs match GitHub CI. Box selection gates on `crabbox status … ready=true` — skipping failed-bootstrap duds (which still report `status=running`) and warming a fresh box if none are ready — keyed on the stable `profile` label rather than an ephemeral slug. A dedicated `agents-cli` crabbox profile (`.crabbox.yaml`) isolates this repo's warm pool. Source: `scripts/sandbox.sh`, `.crabbox.yaml`.
11
+
5
12
  ## 1.20.31
6
13
 
7
14
  **`agents sessions <id>`: a catch-up digest for switching between many agents (#502)**
package/README.md CHANGED
@@ -230,6 +230,30 @@ Profile YAML has no secrets -- safe to `agents repo push` to a shared repo. `age
230
230
 
231
231
  ---
232
232
 
233
+ ## Run on your own machines
234
+
235
+ Dispatch any read-only or config command -- and `agents run` itself -- to another machine over SSH. No daemon.
236
+
237
+ ```bash
238
+ # Enroll a machine (from ~/.ssh/config, or inline with user@address)
239
+ agents hosts add gpu-box
240
+ agents hosts check gpu-box # reachable? which agents-cli version?
241
+
242
+ # Run there instead of locally
243
+ agents run claude --host gpu-box "profile this build"
244
+ agents view claude --host gpu-box # inspect the remote install
245
+ agents sync --host gpu-box # make the remote machine current
246
+
247
+ # Your Tailscale fleet, auto-discovered
248
+ agents devices sync # ingest `tailscale status`
249
+ agents ssh mac-mini # hardened SSH: fails fast if offline,
250
+ # PowerShell on Windows, password-from-Keychain
251
+ ```
252
+
253
+ **Hosts** (`agents hosts`) are git-synced dispatch targets in `agents.yaml`; **devices** (`agents devices`) are your Tailscale machines in a local registry. Both ride SSH. See [docs/00-concepts.md](docs/00-concepts.md#devices--hosts).
254
+
255
+ ---
256
+
233
257
  ## Teams
234
258
 
235
259
  ```bash
@@ -646,11 +670,13 @@ By default, secrets sync via iCloud Keychain to your other Macs. With `--no-iclo
646
670
 
647
671
  Which DotAgents resources each agent CLI can load. Source of truth: [src/lib/agents.ts](src/lib/agents.ts) (`capabilities`); gates use `supports(agent, cap, version)` from [src/lib/capabilities.ts](src/lib/capabilities.ts). Full matrix also in [docs/00-concepts.md](docs/00-concepts.md).
648
672
 
673
+ > **† Gemini CLI is deprecated.** Google retired it for free, Pro, and Ultra tiers on **June 18, 2026** (announced at Google I/O 2026); the `gemini` command no longer serves requests on those tiers. agents-cli still manages existing installs, but warns on `agents add gemini` and `agents teams add … gemini`. New setups should use **Antigravity CLI** (`antigravity`), Google's official successor — see [the transition notice](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/).
674
+
649
675
  | Agent | Versions | Hooks | MCP | Permissions | Skills | Commands | Plugins | Subagents | Rules | Workflows |
650
676
  |-------|----------|-------|-----|-------------|--------|----------|---------|-----------|-------|-----------|
651
677
  | Claude Code | yes | yes | yes | yes | yes | yes | yes | yes | `CLAUDE.md` | yes |
652
678
  | Codex CLI | yes | >= 0.116.0 | yes | no | yes | < 0.117.0 · skills ($name, >= 0.117) | >= 0.128.0 | no | `AGENTS.md` | no |
653
- | Gemini CLI | yes | >= 0.26.0 | yes | no | yes | yes (.toml) | no | no | `GEMINI.md` | no |
679
+ | Gemini CLI | yes | >= 0.26.0 | yes | no | yes | yes (.toml) | no | no | `GEMINI.md` | no |
654
680
  | Antigravity | yes | yes | yes | yes | yes | yes | yes | no | `AGENTS.md` | no |
655
681
  | Grok Build | yes | yes | yes | yes | yes | skills ($name) | yes | no | `AGENTS.md` | no |
656
682
  | OpenClaw | yes | yes | yes | no | yes | gateway | yes | yes | `workspace/AGENTS.md` | no |
@@ -672,7 +698,7 @@ Which DotAgents resources each agent CLI can load. Source of truth: [src/lib/age
672
698
  |-------|----------|-------|---------------|
673
699
  | Claude Code | yes | yes | yes |
674
700
  | Codex CLI | yes | yes | yes |
675
- | Gemini CLI | yes | yes | yes |
701
+ | Gemini CLI | yes | yes | yes |
676
702
  | Cursor | -- | yes | -- |
677
703
  | OpenCode | -- | yes | -- |
678
704
  | Grok Build | -- | yes | yes |
@@ -12,6 +12,29 @@ export declare function shouldBlockOffPlatform(opts: {
12
12
  tcpConfigured: boolean;
13
13
  host?: string;
14
14
  }): boolean;
15
+ /**
16
+ * Sniff the image format from the leading magic bytes: PNG starts with the
17
+ * 8-byte signature `89 50 4E 47` ("\x89PNG"), JPEG with `FF D8 FF`. Returns the
18
+ * canonical file extension, or null for anything else.
19
+ */
20
+ export declare function detectImageFormat(buf: Buffer): '.png' | '.jpg' | null;
21
+ /**
22
+ * Make the screenshot filename honest about its bytes. The two helper backends
23
+ * encode DIFFERENT formats and neither re-encodes to match the requested name:
24
+ * the macOS helper (ScreenCaptureKit) returns JPEG
25
+ * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
26
+ * the Windows helper returns PNG
27
+ * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
28
+ * cannot be correct for both — the only honest path is to sniff the real format
29
+ * and swap the extension to match. Pure so it's unit-testable.
30
+ *
31
+ * Returns the path to write to (caller's path with its extension corrected) and
32
+ * whether a correction was made. Unknown formats pass through unchanged.
33
+ */
34
+ export declare function reconcileScreenshotExt(outPath: string, buf: Buffer): {
35
+ path: string;
36
+ corrected: boolean;
37
+ };
15
38
  export declare function registerComputerCommand(program: Command): void;
16
39
  export declare function registerComputerSubcommands(program: Command): void;
17
40
  export { resolveHelperExec as resolveHelperPath };
@@ -33,6 +33,42 @@ export function shouldBlockOffPlatform(opts) {
33
33
  return false; // remote path resolves its own endpoint
34
34
  return true;
35
35
  }
36
+ /**
37
+ * Sniff the image format from the leading magic bytes: PNG starts with the
38
+ * 8-byte signature `89 50 4E 47` ("\x89PNG"), JPEG with `FF D8 FF`. Returns the
39
+ * canonical file extension, or null for anything else.
40
+ */
41
+ export function detectImageFormat(buf) {
42
+ if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
43
+ return '.png';
44
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
45
+ return '.jpg';
46
+ return null;
47
+ }
48
+ /**
49
+ * Make the screenshot filename honest about its bytes. The two helper backends
50
+ * encode DIFFERENT formats and neither re-encodes to match the requested name:
51
+ * the macOS helper (ScreenCaptureKit) returns JPEG
52
+ * (packages/computer-helper/Sources/ComputerHelper/Screenshot.swift:207,212),
53
+ * the Windows helper returns PNG
54
+ * (packages/computer-helper-win/Screenshot.cs:33). So a fixed default extension
55
+ * cannot be correct for both — the only honest path is to sniff the real format
56
+ * and swap the extension to match. Pure so it's unit-testable.
57
+ *
58
+ * Returns the path to write to (caller's path with its extension corrected) and
59
+ * whether a correction was made. Unknown formats pass through unchanged.
60
+ */
61
+ export function reconcileScreenshotExt(outPath, buf) {
62
+ const actual = detectImageFormat(buf);
63
+ if (!actual)
64
+ return { path: outPath, corrected: false };
65
+ const cur = path.extname(outPath).toLowerCase();
66
+ const alreadyMatches = cur === actual || (actual === '.jpg' && (cur === '.jpg' || cur === '.jpeg'));
67
+ if (alreadyMatches)
68
+ return { path: outPath, corrected: false };
69
+ const base = outPath.slice(0, outPath.length - path.extname(outPath).length);
70
+ return { path: base + actual, corrected: true };
71
+ }
36
72
  export function registerComputerCommand(program) {
37
73
  const computer = program
38
74
  .command('computer')
@@ -128,8 +164,8 @@ function registerScreenshotCommand(program) {
128
164
  .option('--list', 'List the app\'s windows (id/title/layer/bounds) instead of capturing — reveals modals/popups')
129
165
  .option('--window-id <n>', 'Capture a specific window by id (from --list)', (v) => parseInt(v, 10))
130
166
  .option('--display', 'Capture the whole display the app is on (composites stacked modals)')
131
- .option('--out <path>', 'Output JPEG path', './computer-screenshot.jpg')
132
- .option('--quality <n>', 'JPEG quality 1-100', (v) => parseInt(v, 10), 85)
167
+ .option('--out <path>', 'Output image path — extension auto-corrected to the encoded format (JPEG on macOS, PNG on a Windows --host)', './computer-screenshot.jpg')
168
+ .option('--quality <n>', 'JPEG quality 1-100 (macOS capture only; the Windows helper encodes lossless PNG and ignores this)', (v) => parseInt(v, 10), 85)
133
169
  .option('--json', 'Emit JSON (metadata for captures; window list for --list)')
134
170
  .action(async (opts) => {
135
171
  const quality = Math.max(1, Math.min(100, opts.quality || 85));
@@ -176,7 +212,10 @@ function registerScreenshotCommand(program) {
176
212
  process.exit(1);
177
213
  }
178
214
  const buf = Buffer.from(b64, 'base64');
179
- const outPath = path.resolve(opts.out);
215
+ // Sniff the real format and correct the extension so the filename never
216
+ // lies about its bytes (macOS -> JPEG, Windows helper -> PNG).
217
+ const requested = path.resolve(opts.out);
218
+ const { path: outPath, corrected } = reconcileScreenshotExt(requested, buf);
180
219
  fs.writeFileSync(outPath, buf);
181
220
  if (opts.json) {
182
221
  // Drop the heavy base64 from the metadata echo; report where it went.
@@ -184,6 +223,9 @@ function registerScreenshotCommand(program) {
184
223
  console.log(JSON.stringify(meta, null, 2));
185
224
  }
186
225
  else {
226
+ if (corrected) {
227
+ console.log(`note: bytes are ${path.extname(outPath).slice(1).toUpperCase()}; corrected extension from ${path.basename(requested)}`);
228
+ }
187
229
  const origin = res.origin || [];
188
230
  const originStr = origin.length === 2 ? `, origin [${origin.join(',')}], scale ${res.scale ?? '?'}` : '';
189
231
  console.log(`saved: ${outPath} (${res.width ?? '?'}x${res.height ?? '?'}, ${buf.byteLength} bytes${originStr})`);
@@ -22,4 +22,14 @@
22
22
  * apply pending sync.
23
23
  */
24
24
  import type { Command } from 'commander';
25
+ /**
26
+ * Windows-only advisory lines. When the effective PowerShell execution policy
27
+ * blocks unsigned local `.ps1` scripts (`Restricted`/`AllSigned`), the generated
28
+ * `agents.ps1` launcher fails in PowerShell even when it is on PATH. Surface the
29
+ * remediation; the `.cmd` companion still works, so this is a warning, not an
30
+ * error, and doctor never auto-changes the policy. Pure — returns `[]` on
31
+ * non-Windows or a permissive policy, so it is testable without invoking
32
+ * PowerShell.
33
+ */
34
+ export declare function execPolicyWarningLines(platform: NodeJS.Platform, policy: string | null): string[];
25
35
  export declare function registerDoctorCommand(program: Command): void;
@@ -12,6 +12,7 @@ import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
12
12
  import { listCliStatus } from '../lib/cli-resources.js';
13
13
  import { setHelpSections } from '../lib/help.js';
14
14
  import { heal, healChangedAnything } from '../lib/heal.js';
15
+ import { blocksLocalScripts, getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
15
16
  import * as fs from 'fs';
16
17
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
17
18
  // ─── overview mode (no target) ────────────────────────────────────────────────
@@ -208,6 +209,48 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
208
209
  for (const err of hostClis.errors) {
209
210
  console.log(` ${chalk.red('err ')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
210
211
  }
212
+ // On Windows a Restricted/AllSigned execution policy silently breaks the
213
+ // generated `agents.ps1` launcher — postinstall diagnoses it interactively,
214
+ // but a non-interactive install never sees that. Surface it here too.
215
+ renderExecPolicyAdvisory();
216
+ }
217
+ // ─── windows execution-policy advisory ─────────────────────────────────────────
218
+ /**
219
+ * Windows-only advisory lines. When the effective PowerShell execution policy
220
+ * blocks unsigned local `.ps1` scripts (`Restricted`/`AllSigned`), the generated
221
+ * `agents.ps1` launcher fails in PowerShell even when it is on PATH. Surface the
222
+ * remediation; the `.cmd` companion still works, so this is a warning, not an
223
+ * error, and doctor never auto-changes the policy. Pure — returns `[]` on
224
+ * non-Windows or a permissive policy, so it is testable without invoking
225
+ * PowerShell.
226
+ */
227
+ export function execPolicyWarningLines(platform, policy) {
228
+ if (platform !== 'win32')
229
+ return [];
230
+ if (!blocksLocalScripts(policy))
231
+ return [];
232
+ return [
233
+ `PowerShell execution policy is ${policy} — it blocks the generated agents.ps1 launcher.`,
234
+ 'Fix: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned',
235
+ 'The agents.cmd shim still works regardless of the policy.',
236
+ ];
237
+ }
238
+ function renderExecPolicyAdvisory() {
239
+ // Only probe the policy on Windows — getEffectiveExecutionPolicy() spawns
240
+ // powershell, which is a wasted (doomed) process on POSIX where the advisory
241
+ // never applies.
242
+ if (process.platform !== 'win32')
243
+ return;
244
+ const lines = execPolicyWarningLines(process.platform, getEffectiveExecutionPolicy());
245
+ if (lines.length === 0)
246
+ return;
247
+ console.log();
248
+ console.log(chalk.bold('Execution policy (Windows)'));
249
+ const [headline, ...rest] = lines;
250
+ console.log(` ${chalk.yellow('warn ')} ${headline}`);
251
+ for (const line of rest) {
252
+ console.log(chalk.gray(` ${line}`));
253
+ }
211
254
  }
212
255
  function parseTargetArg(arg) {
213
256
  const at = arg.indexOf('@');
@@ -547,6 +590,12 @@ export function registerDoctorCommand(program) {
547
590
  return;
548
591
  }
549
592
  renderOverviewText(clis, syncRows, orphanRows, hostClis);
593
+ // Point at the interactive reconcile when anything is out of sync — the
594
+ // report shouldn't be a dead end. `agents status` runs the unified
595
+ // home-reading engine and offers to sync (opt-in, never auto-fires here).
596
+ if (syncRows.some((r) => r.status !== 'fresh')) {
597
+ console.log(chalk.gray('\nRun `agents status` to review and sync what has drifted.'));
598
+ }
550
599
  return;
551
600
  }
552
601
  const parsed = parseTargetArg(target);
@@ -263,7 +263,7 @@ When to use:
263
263
  pointing at the existing install — nothing is copied or moved (except the
264
264
  agent's config dir, which is moved into the version's home). Works for both
265
265
  npm-style packages (claude, codex, gemini, opencode, openclaw) and
266
- installScript-based agents (grok, antigravity, cursor, kiro, goose, roo).
266
+ installScript-based agents (grok, antigravity, cursor, kiro, goose).
267
267
  `)
268
268
  .action(runImport);
269
269
  }
@@ -51,7 +51,7 @@ Project rules & @-imports:
51
51
  @path imports inside AGENTS.md/CLAUDE.md are resolved at session start by the agent
52
52
  itself, not by agents-cli. Support is per-agent:
53
53
  Inlined natively: claude, gemini
54
- Literal text: codex, cursor, opencode, copilot, amp, kiro, goose, roo
54
+ Literal text: codex, cursor, opencode, copilot, amp, kiro, goose
55
55
 
56
56
  For rules that need to work across all agents, inline the content rather than using
57
57
  @-imports — the second group will load '@path/to/file.md' as a literal string.
@@ -23,7 +23,7 @@ import chalk from 'chalk';
23
23
  import * as crypto from 'crypto';
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
- import { deleteKeychainToken, getKeychainToken, hasKeychainToken, listKeychainItems, setKeychainToken, } from '../lib/secrets/index.js';
26
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, listLegacyKeychainItems, setKeychainToken, } from '../lib/secrets/index.js';
27
27
  import { getBackupsDir } from '../lib/state.js';
28
28
  import { encryptBlob, MIN_PASSPHRASE_LEN } from '../lib/secrets/sync.js';
29
29
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -104,6 +104,7 @@ export function registerSecretsMigrateAclCommand(secrets) {
104
104
  .description('Refresh existing keychain ACLs to use the signed Agents CLI helper. Dry-run by default.')
105
105
  .option('--commit', 'Perform writes (default is dry-run reporting only)')
106
106
  .option('--prefix <p>', `Restrict to items beginning with PREFIX (default ${ITEM_PREFIX})`, ITEM_PREFIX)
107
+ .option('--all', 'Rewrite EVERY matching item, not just legacy stragglers (slower; a Touch ID prompt per item)')
107
108
  .option('--passphrase-env <var>', 'Read the backup passphrase from this env var instead of prompting')
108
109
  .action(async (opts) => {
109
110
  try {
@@ -114,15 +115,24 @@ export function registerSecretsMigrateAclCommand(secrets) {
114
115
  if (!prefix.startsWith(ITEM_PREFIX)) {
115
116
  throw new Error(`--prefix must start with '${ITEM_PREFIX}' to avoid touching unrelated Keychain items (got '${prefix}').`);
116
117
  }
117
- const items = listKeychainItems(prefix).map((item) => {
118
+ // Default: migrate ONLY legacy stragglers (items still in the file-based
119
+ // keychain) — modern DP items need no rewrite and touching them is a
120
+ // Touch ID prompt per item for nothing. `--all` forces the old full
121
+ // rewrite. Either way this is one command over every matching item — no
122
+ // one-by-one invocation.
123
+ const names = opts.all ? listKeychainItems(prefix) : listLegacyKeychainItems(prefix);
124
+ const items = names.map((item) => {
118
125
  const localExists = hasKeychainToken(item);
119
126
  return { item, sync: !localExists };
120
127
  });
121
128
  if (items.length === 0) {
122
- console.log(chalk.gray(`No keychain items with prefix '${prefix}'.`));
129
+ console.log(opts.all
130
+ ? chalk.gray(`No keychain items with prefix '${prefix}'.`)
131
+ : chalk.green(`Nothing to migrate — all items under '${prefix}' already use the modern ACL.`));
123
132
  return;
124
133
  }
125
- console.log(chalk.bold(`Found ${items.length} item(s) under '${prefix}'.`));
134
+ const label = opts.all ? 'item' : 'legacy item';
135
+ console.log(chalk.bold(`Found ${items.length} ${label}(s) under '${prefix}' to migrate.`));
126
136
  if (!opts.commit) {
127
137
  for (const { item, sync } of items) {
128
138
  console.log(` ${chalk.cyan(item)} ${chalk.gray(sync ? '(synced)' : '(local)')}`);
@@ -131,16 +141,18 @@ export function registerSecretsMigrateAclCommand(secrets) {
131
141
  console.log(chalk.gray('Dry-run — pass --commit to perform the migration.'));
132
142
  return;
133
143
  }
134
- // Commit phase. Snapshot every value first, encrypt, then mutate.
144
+ // Commit phase. Snapshot every value first (one batched read behind a
145
+ // single helper process, so DP items share one auth context), encrypt,
146
+ // then mutate.
147
+ const fetched = getKeychainTokens(items.map((i) => i.item));
135
148
  const records = [];
136
149
  for (const { item, sync } of items) {
137
- try {
138
- const value = getKeychainToken(item);
139
- records.push({ item, sync, value });
140
- }
141
- catch (err) {
142
- console.error(chalk.red(`Skipping '${item}': read failed (${err.message}).`));
150
+ const value = fetched.get(item);
151
+ if (value === undefined) {
152
+ console.error(chalk.red(`Skipping '${item}': read failed or item absent.`));
153
+ continue;
143
154
  }
155
+ records.push({ item, sync, value });
144
156
  }
145
157
  if (records.length === 0) {
146
158
  console.error(chalk.red('No items could be read. Aborting before any writes.'));
@@ -18,3 +18,23 @@ export { SSH_TARGET_RE, assertValidSshTarget };
18
18
  export declare function bundleEnvToDotenv(env: Record<string, string>): string;
19
19
  /** Register the `agents secrets` command tree. */
20
20
  export declare function registerSecretsCommands(program: Command): void;
21
+ /**
22
+ * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
23
+ * `spawn(..., { shell: true })` on win32 (`agents secrets exec`). cmd.exe does
24
+ * NO quoting of its own, so an unquoted arg with a space is split into several
25
+ * args, and a cmd metacharacter (`&|<>()^`) would be interpreted by the shell.
26
+ * We wrap any arg with whitespace, a quote, or a metacharacter in double quotes
27
+ * and escape embedded quotes / trailing backslashes per the CommandLineToArgvW
28
+ * rules, so the *child's* argv parse reconstructs the original argument.
29
+ *
30
+ * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
31
+ * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
32
+ * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
33
+ * `agents secrets exec` runs a caller-supplied command against a bundle the
34
+ * caller owns, so caller-controlled `%`/`!` is not a privilege boundary. If that
35
+ * ever changes (exec'ing an untrusted command line), route through a shell that
36
+ * disables expansion rather than relying on this quoter. An empty arg becomes
37
+ * `""`. Exported for tests. No-ops on non-Windows (the caller only invokes it
38
+ * under `process.platform === 'win32'`).
39
+ */
40
+ export declare function quoteWin32ExecArg(arg: string): string;
@@ -1325,8 +1325,18 @@ Examples:
1325
1325
  secretEnv = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` }).env;
1326
1326
  }
1327
1327
  const { spawn } = await import('child_process');
1328
- const proc = spawn(cmd, args, {
1328
+ // On Windows, spawn without a shell ENOENTs for `.cmd`/`.bat` launchers
1329
+ // (npm, yarn, most JS CLIs) and shell built-ins, so we set shell:true.
1330
+ // With shell:true Node hands cmd.exe a single command line with NO quoting
1331
+ // of its own, so args containing spaces or cmd metacharacters must be
1332
+ // quoted here (quoteWin32ExecArg) or they'd be split. See that helper for
1333
+ // the cmd.exe %VAR%/!VAR! expansion caveat.
1334
+ const useShell = process.platform === 'win32';
1335
+ const spawnCmd = useShell ? quoteWin32ExecArg(cmd) : cmd;
1336
+ const spawnArgs = useShell ? args.map(quoteWin32ExecArg) : args;
1337
+ const proc = spawn(spawnCmd, spawnArgs, {
1329
1338
  stdio: 'inherit',
1339
+ shell: useShell,
1330
1340
  env: { ...process.env, ...secretEnv },
1331
1341
  });
1332
1342
  proc.on('close', (code) => process.exit(code ?? 0));
@@ -1619,6 +1629,48 @@ function humanRemaining(expiresAt) {
1619
1629
  const days = Math.round(hours / 24);
1620
1630
  return `locks in ${days} day${days === 1 ? '' : 's'}`;
1621
1631
  }
1632
+ /**
1633
+ * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
1634
+ * `spawn(..., { shell: true })` on win32 (`agents secrets exec`). cmd.exe does
1635
+ * NO quoting of its own, so an unquoted arg with a space is split into several
1636
+ * args, and a cmd metacharacter (`&|<>()^`) would be interpreted by the shell.
1637
+ * We wrap any arg with whitespace, a quote, or a metacharacter in double quotes
1638
+ * and escape embedded quotes / trailing backslashes per the CommandLineToArgvW
1639
+ * rules, so the *child's* argv parse reconstructs the original argument.
1640
+ *
1641
+ * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
1642
+ * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
1643
+ * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
1644
+ * `agents secrets exec` runs a caller-supplied command against a bundle the
1645
+ * caller owns, so caller-controlled `%`/`!` is not a privilege boundary. If that
1646
+ * ever changes (exec'ing an untrusted command line), route through a shell that
1647
+ * disables expansion rather than relying on this quoter. An empty arg becomes
1648
+ * `""`. Exported for tests. No-ops on non-Windows (the caller only invokes it
1649
+ * under `process.platform === 'win32'`).
1650
+ */
1651
+ export function quoteWin32ExecArg(arg) {
1652
+ if (arg.length > 0 && !/[\s"&|<>()^]/.test(arg))
1653
+ return arg;
1654
+ let result = '"';
1655
+ let backslashes = 0;
1656
+ for (const ch of arg) {
1657
+ if (ch === '\\') {
1658
+ backslashes += 1;
1659
+ continue;
1660
+ }
1661
+ if (ch === '"') {
1662
+ // Double the run of backslashes, then escape this quote.
1663
+ result += '\\'.repeat(backslashes * 2 + 1) + '"';
1664
+ backslashes = 0;
1665
+ continue;
1666
+ }
1667
+ result += '\\'.repeat(backslashes) + ch;
1668
+ backslashes = 0;
1669
+ }
1670
+ // Trailing backslashes precede the closing quote → must be doubled.
1671
+ result += '\\'.repeat(backslashes * 2) + '"';
1672
+ return result;
1673
+ }
1622
1674
  /**
1623
1675
  * Copy text to the system clipboard, cross-platform.
1624
1676
  * macOS: `pbcopy`. Windows: `clip`. Linux: tries `wl-copy` (Wayland), then
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `agents status` — the unified sync-status surface.
3
+ *
4
+ * One command that answers "is my fleet in sync?" the same way every other
5
+ * surface does, because it reads the same engine (computeSyncStatus). Human mode
6
+ * renders the summary and, when a TTY finds drift, offers the interactive
7
+ * "sync now?" flow (promptDriftSync). `--json` emits the stable UnifiedSyncStatus
8
+ * contract the menu-bar and Agency consume. `--yes` reconciles everything with no
9
+ * prompts (the "kick it" path, safe in scripts).
10
+ */
11
+ import { Command } from 'commander';
12
+ export declare function registerStatusCommand(program: Command): void;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `agents status` — the unified sync-status surface.
3
+ *
4
+ * One command that answers "is my fleet in sync?" the same way every other
5
+ * surface does, because it reads the same engine (computeSyncStatus). Human mode
6
+ * renders the summary and, when a TTY finds drift, offers the interactive
7
+ * "sync now?" flow (promptDriftSync). `--json` emits the stable UnifiedSyncStatus
8
+ * contract the menu-bar and Agency consume. `--yes` reconciles everything with no
9
+ * prompts (the "kick it" path, safe in scripts).
10
+ */
11
+ import chalk from 'chalk';
12
+ import { AGENTS } from '../lib/agents.js';
13
+ import { setHelpSections } from '../lib/help.js';
14
+ import { computeSyncStatus } from '../lib/sync-status.js';
15
+ import { promptDriftSync } from '../lib/drift-sync.js';
16
+ const agentName = (id) => AGENTS[id]?.name ?? id;
17
+ function versionSummary(v) {
18
+ if (!v.everSynced)
19
+ return chalk.gray('never synced');
20
+ if (v.needsSync) {
21
+ const bits = [];
22
+ if (v.counts.drifted)
23
+ bits.push(`${v.counts.drifted} drifted`);
24
+ if (v.counts.missing)
25
+ bits.push(`${v.counts.missing} missing`);
26
+ return chalk.yellow(bits.join(' · '));
27
+ }
28
+ return chalk.green('in sync');
29
+ }
30
+ export function registerStatusCommand(program) {
31
+ const cmd = program
32
+ .command('status')
33
+ .description('Unified sync status across the fleet — what is drifted, missing, or behind, with an option to sync it.')
34
+ .option('--json', 'Output the machine-readable UnifiedSyncStatus contract')
35
+ .option('--yes', 'Reconcile everything detected (pull .system if behind + sync drifted/missing resources) without prompting')
36
+ .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())');
37
+ setHelpSections(cmd, {
38
+ examples: `
39
+ # Show what's out of sync; offer to fix it (interactive)
40
+ agents status
41
+
42
+ # Machine-readable status for the menu-bar / Agency
43
+ agents status --json
44
+
45
+ # Reconcile everything with no prompts (CI / scripts / the "kick it" path)
46
+ agents status --yes
47
+ `,
48
+ });
49
+ cmd.action(async (opts) => {
50
+ const cwd = opts.cwd ?? process.cwd();
51
+ if (opts.json) {
52
+ const status = await computeSyncStatus({ cwd });
53
+ console.log(JSON.stringify(status, null, 2));
54
+ return;
55
+ }
56
+ const status = await computeSyncStatus({ cwd });
57
+ // Human summary header (always) — the per-version readout.
58
+ console.log(chalk.bold('Fleet sync status'));
59
+ if (status.system.unknown) {
60
+ console.log(` ${'.system repo'.padEnd(28)} ${chalk.gray('freshness unknown (no upstream)')}`);
61
+ }
62
+ else if (status.system.behind > 0) {
63
+ console.log(` ${'.system repo'.padEnd(28)} ${chalk.yellow(`${status.system.behind} behind`)}`);
64
+ }
65
+ else {
66
+ console.log(` ${'.system repo'.padEnd(28)} ${chalk.green('up to date')}`);
67
+ }
68
+ if (status.agents.length === 0) {
69
+ console.log(chalk.gray(' (no installed agent versions)'));
70
+ }
71
+ for (const v of status.agents) {
72
+ const label = `${agentName(v.agent)}@${v.version}${v.isDefault ? chalk.gray(' (default)') : ''}`;
73
+ console.log(` ${label.padEnd(28)} ${versionSummary(v)}`);
74
+ }
75
+ if (status.totals.orphan > 0) {
76
+ console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
77
+ }
78
+ // Hand off to the shared interactive/apply flow (summary already printed above).
79
+ await promptDriftSync({ cwd, yes: opts.yes, status, quiet: true });
80
+ });
81
+ }