@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.
- package/CHANGELOG.md +7 -0
- package/README.md +28 -2
- package/dist/commands/computer.d.ts +23 -0
- package/dist/commands/computer.js +45 -3
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +49 -0
- package/dist/commands/import.js +1 -1
- package/dist/commands/rules.js +1 -1
- package/dist/commands/secrets-migrate.js +23 -11
- package/dist/commands/secrets.d.ts +20 -0
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/status.d.ts +12 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/teams.js +70 -6
- package/dist/commands/versions.js +2 -1
- package/dist/commands/view.d.ts +39 -0
- package/dist/commands/view.js +194 -75
- package/dist/index.js +4 -2
- package/dist/lib/acp/harnesses.d.ts +1 -1
- package/dist/lib/acp/harnesses.js +2 -2
- package/dist/lib/agents.d.ts +12 -0
- package/dist/lib/agents.js +115 -32
- package/dist/lib/browser/chrome.js +20 -0
- package/dist/lib/browser/drivers/ssh.d.ts +19 -0
- package/dist/lib/browser/drivers/ssh.js +18 -3
- package/dist/lib/doctor-diff.js +29 -2
- package/dist/lib/drift-sync.d.ts +43 -0
- package/dist/lib/drift-sync.js +179 -0
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +21 -11
- package/dist/lib/platform/winpath.d.ts +31 -2
- package/dist/lib/platform/winpath.js +133 -24
- package/dist/lib/pwsh.d.ts +11 -0
- package/dist/lib/pwsh.js +13 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +42 -1
- package/dist/lib/secrets/agent.js +89 -11
- package/dist/lib/secrets/bundles.js +40 -9
- package/dist/lib/secrets/filestore.js +31 -1
- package/dist/lib/secrets/index.d.ts +33 -1
- package/dist/lib/secrets/index.js +90 -9
- package/dist/lib/secrets/windows.d.ts +74 -0
- package/dist/lib/secrets/windows.js +440 -0
- package/dist/lib/shims.d.ts +20 -0
- package/dist/lib/shims.js +53 -20
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-status.d.ts +102 -0
- package/dist/lib/sync-status.js +135 -0
- package/dist/lib/teams/agents.d.ts +24 -0
- package/dist/lib/teams/agents.js +30 -1
- package/dist/lib/types.d.ts +20 -1
- package/dist/lib/usage.d.ts +30 -0
- package/dist/lib/usage.js +159 -2
- 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
|
|
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
|
-
|
|
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;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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);
|
package/dist/commands/import.js
CHANGED
|
@@ -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
|
|
266
|
+
installScript-based agents (grok, antigravity, cursor, kiro, goose).
|
|
267
267
|
`)
|
|
268
268
|
.action(runImport);
|
|
269
269
|
}
|
package/dist/commands/rules.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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;
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|