@phnx-labs/agents-cli 1.14.6 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -1
- package/dist/commands/beta.js +6 -1
- package/dist/commands/exec.js +9 -2
- package/dist/commands/init.js +10 -0
- package/dist/commands/mcp.js +4 -4
- package/dist/commands/prune.d.ts +0 -20
- package/dist/commands/prune.js +268 -15
- package/dist/commands/secrets.js +83 -0
- package/dist/commands/teams.js +2 -3
- package/dist/commands/usage.js +6 -0
- package/dist/commands/versions.js +8 -6
- package/dist/lib/browser/chrome.js +1 -1
- package/dist/lib/browser/drivers/ssh.d.ts +1 -0
- package/dist/lib/browser/drivers/ssh.js +23 -2
- package/dist/lib/browser/ipc.js +1 -0
- package/dist/lib/browser/service.d.ts +3 -0
- package/dist/lib/browser/service.js +114 -6
- package/dist/lib/daemon.js +4 -4
- package/dist/lib/events.d.ts +159 -0
- package/dist/lib/events.js +441 -0
- package/dist/lib/exec.js +29 -6
- package/dist/lib/permissions.d.ts +6 -3
- package/dist/lib/permissions.js +38 -34
- package/dist/lib/routines.d.ts +15 -0
- package/dist/lib/routines.js +68 -0
- package/dist/lib/runner.js +15 -0
- package/dist/lib/secrets/bundles.js +7 -1
- package/dist/lib/secrets/index.d.ts +14 -11
- package/dist/lib/secrets/index.js +49 -21
- package/dist/lib/secrets/linux.d.ts +27 -0
- package/dist/lib/secrets/linux.js +161 -0
- package/dist/lib/session/db.d.ts +4 -0
- package/dist/lib/session/db.js +26 -0
- package/dist/lib/skills.js +4 -0
- package/dist/lib/usage.d.ts +1 -1
- package/dist/lib/usage.js +13 -46
- package/dist/lib/versions.js +16 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +37 -9
package/README.md
CHANGED
|
@@ -47,11 +47,13 @@ Also available as `ag` -- all commands work with both `agents` and `ag`.
|
|
|
47
47
|
- [Sessions across agents](#sessions-across-agents)
|
|
48
48
|
- [Run open models through Claude Code](#run-open-models-through-claude-code)
|
|
49
49
|
- [Teams](#teams)
|
|
50
|
+
- [Browser](#browser)
|
|
50
51
|
- [Secrets](#secrets)
|
|
51
52
|
- [Routines](#routines)
|
|
52
53
|
- [PTY](#pty)
|
|
53
54
|
- [Portable setup](#portable-setup)
|
|
54
55
|
- [Private skills](#private-skills)
|
|
56
|
+
- [Security & Privacy](#security--privacy)
|
|
55
57
|
- [Compatibility](#compatibility)
|
|
56
58
|
- [FAQ](#faq)
|
|
57
59
|
|
|
@@ -242,6 +244,85 @@ Team state is observable via `agents teams list --json` / `agents teams status -
|
|
|
242
244
|
|
|
243
245
|
---
|
|
244
246
|
|
|
247
|
+
## Browser
|
|
248
|
+
|
|
249
|
+
Give agents access to a real browser — no relay extension, no cloud service, no Playwright getting blocked.
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
# Create an isolated profile for automation
|
|
253
|
+
agents browser profiles create work --browser chrome
|
|
254
|
+
|
|
255
|
+
# Start a task, navigate, interact
|
|
256
|
+
agents browser start login-flow --profile work
|
|
257
|
+
agents browser navigate login-flow https://app.example.com
|
|
258
|
+
agents browser refs login-flow # Get interactive element refs
|
|
259
|
+
agents browser click login-flow <tab> 42 # Click element ref 42
|
|
260
|
+
agents browser type login-flow <tab> 15 "hello"
|
|
261
|
+
agents browser screenshot login-flow # Smart resizing, token-efficient
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Why this works where Playwright fails
|
|
265
|
+
|
|
266
|
+
Playwright and Puppeteer spin up fresh browser instances with automation flags. Sites like LinkedIn, Google, and most finance apps detect and block them immediately.
|
|
267
|
+
|
|
268
|
+
`agents browser` launches your existing residential Chrome (or Brave, Edge, Chromium) on your machine via CDP. Same browser fingerprint, same IP, same everything. Sites can't detect automation because you're using the same browser you'd use manually.
|
|
269
|
+
|
|
270
|
+
### Token-efficient automation
|
|
271
|
+
|
|
272
|
+
The CLI handles the mechanical work so agents don't burn tokens on low-level browser commands. Screenshots are automatically resized without excessive compression — agents process smaller images while keeping the detail they need to make decisions.
|
|
273
|
+
|
|
274
|
+
### Profile isolation
|
|
275
|
+
|
|
276
|
+
Multiple agents can run browser tasks simultaneously without stepping on each other. Each profile gets its own user data directory, cookies, and state. One agent logs into your work Slack, another into your personal email — no conflicts, no shared state.
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
agents browser profiles create work-slack --browser chrome
|
|
280
|
+
agents browser profiles create personal-gmail --browser chrome
|
|
281
|
+
# Two agents, two profiles, no interference
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Safe credential access
|
|
285
|
+
|
|
286
|
+
Attach a [secrets bundle](#secrets) to a profile. The agent can log in without credentials in plaintext, and every secret access is recorded in the session log.
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
agents browser profiles create bank --browser chrome --secrets bank-creds
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Electron apps
|
|
293
|
+
|
|
294
|
+
Control Electron apps (Slack, Discord, VS Code, your own app) with custom binaries:
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
agents browser profiles create rush \
|
|
298
|
+
--browser custom \
|
|
299
|
+
--binary "/Applications/Rush.app/Contents/MacOS/Rush" \
|
|
300
|
+
--electron
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Remote browsers
|
|
304
|
+
|
|
305
|
+
Connect to browsers running anywhere — local, SSH tunnels, or cloud services:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
# Local CDP (discovers WebSocket URL automatically)
|
|
309
|
+
agents browser profiles create local-debug \
|
|
310
|
+
--browser chrome \
|
|
311
|
+
--endpoint "http://localhost:9222"
|
|
312
|
+
|
|
313
|
+
# SSH tunnel to a remote machine
|
|
314
|
+
agents browser profiles create staging \
|
|
315
|
+
--browser chrome \
|
|
316
|
+
--endpoint "ssh://deploy@staging.example.com?port=9222"
|
|
317
|
+
|
|
318
|
+
# Cloud browser services (BrowserBase, Steel, etc.)
|
|
319
|
+
agents browser profiles create cloud \
|
|
320
|
+
--browser chrome \
|
|
321
|
+
--endpoint "wss://connect.browserbase.com?apiKey=..."
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
245
326
|
## Secrets
|
|
246
327
|
|
|
247
328
|
```bash
|
|
@@ -373,6 +454,70 @@ Extras clone into `~/.agents-system/.repos/<alias>/` and ship the same layout as
|
|
|
373
454
|
|
|
374
455
|
---
|
|
375
456
|
|
|
457
|
+
## Security & Privacy
|
|
458
|
+
|
|
459
|
+
**Everything stays on your machine.** No telemetry, no cloud sync (unless you opt into iCloud Keychain for secrets), no phone-home. Here's exactly what `agents-cli` stores locally and why.
|
|
460
|
+
|
|
461
|
+
### Event log
|
|
462
|
+
|
|
463
|
+
Every agent run, version install, browser launch, and secrets access is logged to `~/.agents/logs/events-YYYY-MM-DD.jsonl`. This gives you a complete record of what agents did on your machine.
|
|
464
|
+
|
|
465
|
+
```bash
|
|
466
|
+
# What gets logged (example event):
|
|
467
|
+
{
|
|
468
|
+
"ts": "2026-05-09T10:23:45Z",
|
|
469
|
+
"event": "agent.run.end",
|
|
470
|
+
"agent": "claude",
|
|
471
|
+
"version": "2.1.121",
|
|
472
|
+
"prompt": "Fix the auth bug in...", # truncated to 200 chars
|
|
473
|
+
"durationMs": 45230,
|
|
474
|
+
"exitCode": 0,
|
|
475
|
+
"hostname": "your-mac",
|
|
476
|
+
"platform": "darwin"
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
**What's logged:** Operation type, agent, version, timing, truncated prompts (first 200 chars), exit codes, errors. **What's NOT logged:** Full prompts, outputs, file contents, secret values (only bundle names).
|
|
481
|
+
|
|
482
|
+
**Permissions:** Logs directory is `0700` (owner-only), files are `0600`. Only you can read them.
|
|
483
|
+
|
|
484
|
+
**Retention:** 30 days by default, then auto-pruned.
|
|
485
|
+
|
|
486
|
+
**Opt out:** Set `AGENTS_DISABLE_EVENT_LOG=1` in your shell to disable completely.
|
|
487
|
+
|
|
488
|
+
### Session search
|
|
489
|
+
|
|
490
|
+
Conversations with Claude, Codex, Gemini, and other agents scatter across their native storage. Session search indexes them locally so you can find any conversation:
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
agents sessions "auth middleware" # Full-text search across all agents
|
|
494
|
+
agents sessions --agent claude --since 7d
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
The index lives at `~/.agents/sessions/sessions.db` (SQLite + FTS5). Nothing leaves your machine. See [Sessions](#sessions-across-agents) for full usage.
|
|
498
|
+
|
|
499
|
+
### Secrets
|
|
500
|
+
|
|
501
|
+
API keys and credentials are stored in macOS Keychain, never in plaintext files. Bundle definitions also live in Keychain.
|
|
502
|
+
|
|
503
|
+
```bash
|
|
504
|
+
agents secrets create my-keys
|
|
505
|
+
agents secrets add my-keys API_KEY # Prompts for value, stores in Keychain
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
With `--icloud-sync`, secrets sync via iCloud Keychain to your other Macs. Without it, they stay device-local. See [Secrets](#secrets) for full usage.
|
|
509
|
+
|
|
510
|
+
### Summary
|
|
511
|
+
|
|
512
|
+
| Data | Location | Who can read | Opt out |
|
|
513
|
+
|------|----------|--------------|---------|
|
|
514
|
+
| Event log | `~/.agents/logs/` | You only (0600) | `AGENTS_DISABLE_EVENT_LOG=1` |
|
|
515
|
+
| Session index | `~/.agents/sessions/` | You only | Delete the directory |
|
|
516
|
+
| Secrets | macOS Keychain | You + apps you authorize | Don't use `agents secrets` |
|
|
517
|
+
| Config | `~/.agents/` | You only | N/A |
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
376
521
|
## Compatibility
|
|
377
522
|
|
|
378
523
|
| Agent | Versions | MCP | Commands | Skills | Rules | Hooks | Plugins | Permissions | Routines | Teams |
|
|
@@ -425,7 +570,9 @@ Your choice. We hand off to the original CLI process — use your existing subsc
|
|
|
425
570
|
|
|
426
571
|
### Does it store my API keys or send telemetry?
|
|
427
572
|
|
|
428
|
-
No. API keys come from your shell environment or each agent CLI's existing auth.
|
|
573
|
+
**No telemetry, no phone-home.** Everything stays local. API keys come from your shell environment or each agent CLI's existing auth.
|
|
574
|
+
|
|
575
|
+
For full transparency: `agents-cli` keeps a local event log at `~/.agents/logs/` so you can see exactly what agents did on your machine. Logs are owner-readable only (0600) and auto-prune after 30 days. Set `AGENTS_DISABLE_EVENT_LOG=1` to disable. See [Security & Privacy](#security--privacy) for details.
|
|
429
576
|
|
|
430
577
|
### Which platforms?
|
|
431
578
|
|
package/dist/commands/beta.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { ALL_BETA_FEATURES, getBetaConfigLocation, getEnabledBetaFeatures, setBetaEnabled, } from '../lib/beta.js';
|
|
3
|
+
const BETA_DESCRIPTIONS = {
|
|
4
|
+
drive: 'Google Drive integration for reading and writing files',
|
|
5
|
+
factory: 'Cloud-based agent dispatch via Rush Factory',
|
|
6
|
+
};
|
|
3
7
|
function parseFeatures(values) {
|
|
4
8
|
const valid = new Set(ALL_BETA_FEATURES);
|
|
5
9
|
const invalid = values.filter((value) => !valid.has(value));
|
|
@@ -29,7 +33,8 @@ Examples:
|
|
|
29
33
|
console.log(chalk.bold('Beta Features'));
|
|
30
34
|
for (const feature of ALL_BETA_FEATURES) {
|
|
31
35
|
const state = enabled.has(feature) ? chalk.green('enabled') : chalk.gray('disabled');
|
|
32
|
-
|
|
36
|
+
const desc = BETA_DESCRIPTIONS[feature] || '';
|
|
37
|
+
console.log(` ${feature.padEnd(10)} ${state.padEnd(18)} ${chalk.dim(desc)}`);
|
|
33
38
|
}
|
|
34
39
|
console.log('');
|
|
35
40
|
console.log(chalk.gray(`Config: ${location.path}`));
|
package/dist/commands/exec.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { buildExecCommand, parseExecEnv, execAgent, runWithFallback, AGENT_COMMANDS, } from '../lib/exec.js';
|
|
10
10
|
import { profileExists, resolveProfileForRun } from '../lib/profiles.js';
|
|
11
|
-
import { readBundle, resolveBundleEnv } from '../lib/secrets/bundles.js';
|
|
11
|
+
import { readBundle, resolveBundleEnv, describeBundle } from '../lib/secrets/bundles.js';
|
|
12
12
|
import { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES, } from '../lib/rotate.js';
|
|
13
13
|
import { resolveVersionAlias } from '../lib/versions.js';
|
|
14
14
|
const VALID_AGENTS = Object.keys(AGENT_COMMANDS);
|
|
@@ -36,7 +36,7 @@ export function registerRunCommand(program) {
|
|
|
36
36
|
.option('--cwd <dir>', 'Working directory for the agent (defaults to current directory)')
|
|
37
37
|
.option('--add-dir <dir>', 'Grant access to an additional directory outside the project (Claude only, repeatable)', (val, prev) => [...prev, val], [])
|
|
38
38
|
.option('--json', 'Stream events as JSON lines (for parsing by other tools)')
|
|
39
|
-
.option('--headless', 'Non-interactive mode (
|
|
39
|
+
.option('--headless', 'Non-interactive mode (auto-enabled when prompt provided)', false)
|
|
40
40
|
.option('-i, --interactive', 'Force interactive mode even when a prompt is provided')
|
|
41
41
|
.option('--session-id <id>', 'Resume a previous conversation (Claude only)')
|
|
42
42
|
.option('--verbose', 'Show detailed execution logs')
|
|
@@ -197,6 +197,13 @@ Examples:
|
|
|
197
197
|
for (const bundleName of options.secrets) {
|
|
198
198
|
try {
|
|
199
199
|
const bundle = readBundle(bundleName);
|
|
200
|
+
const entries = describeBundle(bundle);
|
|
201
|
+
const counts = {};
|
|
202
|
+
for (const e of entries) {
|
|
203
|
+
counts[e.kind] = (counts[e.kind] || 0) + 1;
|
|
204
|
+
}
|
|
205
|
+
const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
|
|
206
|
+
console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
|
|
200
207
|
secretsEnv = { ...secretsEnv, ...resolveBundleEnv(bundle) };
|
|
201
208
|
}
|
|
202
209
|
catch (err) {
|
package/dist/commands/init.js
CHANGED
|
@@ -82,6 +82,16 @@ export async function runInit(program, options = {}) {
|
|
|
82
82
|
spinner.succeed(`Updated to ${result.commit}`);
|
|
83
83
|
}
|
|
84
84
|
else {
|
|
85
|
+
// Check git is available
|
|
86
|
+
try {
|
|
87
|
+
const { execSync } = await import('child_process');
|
|
88
|
+
execSync('which git', { stdio: 'ignore' });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
spinner.fail('git is not installed');
|
|
92
|
+
console.log(chalk.gray('Install git first: https://git-scm.com/downloads'));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
85
95
|
const result = await cloneIntoExisting(DEFAULT_SYSTEM_REPO, agentsDir);
|
|
86
96
|
if (!result.success) {
|
|
87
97
|
spinner.fail(`Clone failed: ${result.error}`);
|
package/dist/commands/mcp.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readManifest, writeManifest, createDefaultManifest } from '../lib/manif
|
|
|
6
6
|
import { listMcpServerConfigs } from '../lib/mcp.js';
|
|
7
7
|
import { getMcpDir } from '../lib/state.js';
|
|
8
8
|
import { getEffectiveHome, getGlobalDefault, listInstalledVersions, getVersionHomePath, resolveInstalledAgentTargets, resolveConfiguredAgentTargets, resolveVersionAlias, } from '../lib/versions.js';
|
|
9
|
-
import {
|
|
9
|
+
import { getUserAgentsDir } from '../lib/state.js';
|
|
10
10
|
import { isPromptCancelled, isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
|
|
11
11
|
import { showResourceList, buildTargetsSection, } from './resource-view.js';
|
|
12
12
|
/** Parse a comma-separated --agents string into validated agent IDs and optional version targets. */
|
|
@@ -163,7 +163,7 @@ Examples:
|
|
|
163
163
|
console.log(chalk.gray('HTTP: agents mcp add <name> <url> --transport http'));
|
|
164
164
|
process.exit(1);
|
|
165
165
|
}
|
|
166
|
-
const localPath =
|
|
166
|
+
const localPath = getUserAgentsDir();
|
|
167
167
|
const manifest = readManifest(localPath) || createDefaultManifest();
|
|
168
168
|
manifest.mcp = manifest.mcp || {};
|
|
169
169
|
const targetConfig = parseMcpAgentTargets(options.agents);
|
|
@@ -400,7 +400,7 @@ Examples:
|
|
|
400
400
|
agents mcp register --agents codex@0.116.0
|
|
401
401
|
`)
|
|
402
402
|
.action(async (name, options) => {
|
|
403
|
-
const localPath =
|
|
403
|
+
const localPath = getUserAgentsDir();
|
|
404
404
|
const manifest = readManifest(localPath);
|
|
405
405
|
if (!manifest?.mcp) {
|
|
406
406
|
console.log(chalk.yellow('No MCP servers in manifest'));
|
|
@@ -467,7 +467,7 @@ function buildMcpRows(opts) {
|
|
|
467
467
|
const centralServers = new Map();
|
|
468
468
|
for (const s of listMcpServerConfigs())
|
|
469
469
|
centralServers.set(s.name, s);
|
|
470
|
-
const manifest = readManifest(
|
|
470
|
+
const manifest = readManifest(getUserAgentsDir());
|
|
471
471
|
const manifestEntries = manifest?.mcp || {};
|
|
472
472
|
const targetPairs = iterMcpCapableVersions({
|
|
473
473
|
agent: opts.filterAgent,
|
package/dist/commands/prune.d.ts
CHANGED
|
@@ -1,22 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Top-level `agents prune` — destructive cleanup across the install.
|
|
3
|
-
*
|
|
4
|
-
* Two kinds of cleanup, one verb:
|
|
5
|
-
* - Resource orphans: command/skill/hook files inside a version home that no
|
|
6
|
-
* longer come from any source (deleted from ~/.agents/ but never reconciled
|
|
7
|
-
* into the version install).
|
|
8
|
-
* - Version duplicates: older installed versions of an agent that share an
|
|
9
|
-
* account with a newer installed version of the same agent (the older copy
|
|
10
|
-
* is redundant; the newer one is what's signed in and active).
|
|
11
|
-
*
|
|
12
|
-
* Sync (additive: copy missing/changed files into version homes) is no longer
|
|
13
|
-
* a user-facing verb — `syncResourcesToVersion` runs at agent launch and
|
|
14
|
-
* applies adds/updates automatically. Pruning, however, is destructive, so it
|
|
15
|
-
* stays explicit.
|
|
16
|
-
*
|
|
17
|
-
* Default scope: each agent's currently-pinned default version for orphan
|
|
18
|
-
* cleanup, plus the standard cross-agent version-dedup pass. Pass `--all`
|
|
19
|
-
* to widen orphan cleanup to every installed version.
|
|
20
|
-
*/
|
|
21
1
|
import type { Command } from 'commander';
|
|
22
2
|
export declare function registerPruneCommand(program: Command): void;
|
package/dist/commands/prune.js
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level `agents prune` — destructive cleanup across the install.
|
|
3
|
+
*
|
|
4
|
+
* Cleanup targets:
|
|
5
|
+
* - Resource orphans: command/skill/hook files inside a version home that no
|
|
6
|
+
* longer come from any source (deleted from ~/.agents/ but never reconciled
|
|
7
|
+
* into the version install).
|
|
8
|
+
* - Version duplicates: older installed versions of an agent that share an
|
|
9
|
+
* account with a newer installed version of the same agent.
|
|
10
|
+
* - Trash: soft-deleted resources in ~/.agents/.trash/ older than N days.
|
|
11
|
+
* - Sessions: session records in sessions.db older than N days.
|
|
12
|
+
* - Runs: routine execution logs, keeping only the last N per job.
|
|
13
|
+
*
|
|
14
|
+
* Sync (additive: copy missing/changed files into version homes) is no longer
|
|
15
|
+
* a user-facing verb — `syncResourcesToVersion` runs at agent launch and
|
|
16
|
+
* applies adds/updates automatically. Pruning, however, is destructive, so it
|
|
17
|
+
* stays explicit.
|
|
18
|
+
*
|
|
19
|
+
* Default scope: each agent's currently-pinned default version for orphan
|
|
20
|
+
* cleanup, plus the standard cross-agent version-dedup pass. Pass `--all`
|
|
21
|
+
* to widen orphan cleanup to every installed version.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
1
25
|
import chalk from 'chalk';
|
|
2
26
|
import { confirm } from '@inquirer/prompts';
|
|
3
27
|
import { diffVersionCommands, iterCommandsCapableVersions, removeCommandFromVersion, } from '../lib/commands.js';
|
|
@@ -7,8 +31,12 @@ import { getGlobalDefault } from '../lib/versions.js';
|
|
|
7
31
|
import { resolveAgentName, formatAgentError } from '../lib/agents.js';
|
|
8
32
|
import { pruneDuplicates } from './view.js';
|
|
9
33
|
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
34
|
+
import { getTrashDir } from '../lib/state.js';
|
|
35
|
+
import { countSessionsOlderThan, deleteSessionsOlderThan } from '../lib/session/db.js';
|
|
36
|
+
import { previewRunsPrune, pruneRuns, countAllRuns } from '../lib/routines.js';
|
|
10
37
|
const RESOURCE_TYPES = ['commands', 'skills', 'hooks'];
|
|
11
|
-
const
|
|
38
|
+
const STATE_TYPES = ['trash', 'sessions', 'runs'];
|
|
39
|
+
const ALL_TYPES = [...RESOURCE_TYPES, 'versions', ...STATE_TYPES];
|
|
12
40
|
function scopePairs(pairs, all) {
|
|
13
41
|
if (all)
|
|
14
42
|
return pairs;
|
|
@@ -59,6 +87,9 @@ function parseTarget(arg) {
|
|
|
59
87
|
if (RESOURCE_TYPES.includes(arg)) {
|
|
60
88
|
return { resourceTypes: [arg], includeVersions: false };
|
|
61
89
|
}
|
|
90
|
+
if (STATE_TYPES.includes(arg)) {
|
|
91
|
+
return { resourceTypes: [], includeVersions: false, stateType: arg };
|
|
92
|
+
}
|
|
62
93
|
if (arg === 'versions') {
|
|
63
94
|
return { resourceTypes: [], includeVersions: true };
|
|
64
95
|
}
|
|
@@ -72,6 +103,202 @@ function parseTarget(arg) {
|
|
|
72
103
|
console.log(chalk.gray(formatAgentError(arg)));
|
|
73
104
|
process.exit(1);
|
|
74
105
|
}
|
|
106
|
+
function parseDays(value, defaultDays) {
|
|
107
|
+
const match = value.match(/^(\d+)d?$/);
|
|
108
|
+
if (match)
|
|
109
|
+
return parseInt(match[1], 10);
|
|
110
|
+
return defaultDays;
|
|
111
|
+
}
|
|
112
|
+
function formatBytes(bytes) {
|
|
113
|
+
if (bytes < 1024)
|
|
114
|
+
return `${bytes} B`;
|
|
115
|
+
if (bytes < 1024 * 1024)
|
|
116
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
117
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
118
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
119
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
120
|
+
}
|
|
121
|
+
function getDirSize(dirPath) {
|
|
122
|
+
if (!fs.existsSync(dirPath))
|
|
123
|
+
return 0;
|
|
124
|
+
let size = 0;
|
|
125
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
size += getDirSize(fullPath);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
try {
|
|
133
|
+
size += fs.statSync(fullPath).size;
|
|
134
|
+
}
|
|
135
|
+
catch { /* ignore */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return size;
|
|
139
|
+
}
|
|
140
|
+
async function runTrashPrune(options) {
|
|
141
|
+
const trashDir = getTrashDir();
|
|
142
|
+
if (!fs.existsSync(trashDir)) {
|
|
143
|
+
console.log(chalk.green('Trash is empty.'));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const days = parseDays(options.olderThan || '30d', 30);
|
|
147
|
+
const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
148
|
+
const toPrune = [];
|
|
149
|
+
function scanDir(dir) {
|
|
150
|
+
if (!fs.existsSync(dir))
|
|
151
|
+
return;
|
|
152
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
153
|
+
const fullPath = path.join(dir, entry.name);
|
|
154
|
+
try {
|
|
155
|
+
const stat = fs.statSync(fullPath);
|
|
156
|
+
if (stat.mtimeMs < cutoffMs) {
|
|
157
|
+
toPrune.push({ path: fullPath, mtime: stat.mtimeMs, size: entry.isDirectory() ? getDirSize(fullPath) : stat.size });
|
|
158
|
+
}
|
|
159
|
+
else if (entry.isDirectory()) {
|
|
160
|
+
scanDir(fullPath);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch { /* skip inaccessible */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
scanDir(trashDir);
|
|
167
|
+
if (toPrune.length === 0) {
|
|
168
|
+
console.log(chalk.green(`No trash entries older than ${days} days.`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const totalSize = toPrune.reduce((sum, e) => sum + e.size, 0);
|
|
172
|
+
console.log(chalk.bold(`Trash entries older than ${days} days\n`));
|
|
173
|
+
for (const entry of toPrune.slice(0, 20)) {
|
|
174
|
+
const age = Math.floor((Date.now() - entry.mtime) / (24 * 60 * 60 * 1000));
|
|
175
|
+
console.log(` ${chalk.gray(`${age}d ago`)} ${path.relative(trashDir, entry.path)}`);
|
|
176
|
+
}
|
|
177
|
+
if (toPrune.length > 20) {
|
|
178
|
+
console.log(chalk.gray(` ... and ${toPrune.length - 20} more`));
|
|
179
|
+
}
|
|
180
|
+
console.log();
|
|
181
|
+
if (options.dryRun) {
|
|
182
|
+
console.log(chalk.gray(`${toPrune.length} entries (${formatBytes(totalSize)}). Run without --dry-run to delete.`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!options.yes) {
|
|
186
|
+
if (!isInteractiveTerminal()) {
|
|
187
|
+
console.log(chalk.yellow('Non-interactive shell: pass -y to confirm, or --dry-run to preview.'));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
let ok = false;
|
|
191
|
+
try {
|
|
192
|
+
ok = await confirm({ message: `Delete ${toPrune.length} entries (${formatBytes(totalSize)})?`, default: false });
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (isPromptCancelled(err)) {
|
|
196
|
+
console.log(chalk.gray('Cancelled'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
if (!ok) {
|
|
202
|
+
console.log(chalk.gray('Cancelled'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let deleted = 0;
|
|
207
|
+
for (const entry of toPrune) {
|
|
208
|
+
try {
|
|
209
|
+
fs.rmSync(entry.path, { recursive: true, force: true });
|
|
210
|
+
deleted++;
|
|
211
|
+
}
|
|
212
|
+
catch { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
console.log(chalk.green(`Pruned ${deleted} trash entries (${formatBytes(totalSize)}).`));
|
|
215
|
+
}
|
|
216
|
+
async function runSessionsPrune(options) {
|
|
217
|
+
const days = parseDays(options.olderThan || '90d', 90);
|
|
218
|
+
const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
219
|
+
const count = countSessionsOlderThan(cutoffMs);
|
|
220
|
+
if (count === 0) {
|
|
221
|
+
console.log(chalk.green(`No sessions older than ${days} days.`));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
console.log(chalk.bold(`Sessions older than ${days} days: ${count}\n`));
|
|
225
|
+
if (options.dryRun) {
|
|
226
|
+
console.log(chalk.gray(`${count} session(s). Run without --dry-run to delete.`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!options.yes) {
|
|
230
|
+
if (!isInteractiveTerminal()) {
|
|
231
|
+
console.log(chalk.yellow('Non-interactive shell: pass -y to confirm, or --dry-run to preview.'));
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
let ok = false;
|
|
235
|
+
try {
|
|
236
|
+
ok = await confirm({ message: `Delete ${count} session records?`, default: false });
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
if (isPromptCancelled(err)) {
|
|
240
|
+
console.log(chalk.gray('Cancelled'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
if (!ok) {
|
|
246
|
+
console.log(chalk.gray('Cancelled'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const deleted = deleteSessionsOlderThan(cutoffMs);
|
|
251
|
+
console.log(chalk.green(`Pruned ${deleted} session records.`));
|
|
252
|
+
}
|
|
253
|
+
async function runRunsPrune(options) {
|
|
254
|
+
const keep = options.keep ? parseInt(options.keep, 10) : 10;
|
|
255
|
+
if (isNaN(keep) || keep < 0) {
|
|
256
|
+
console.log(chalk.red('--keep must be a non-negative integer'));
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const preview = previewRunsPrune(keep);
|
|
260
|
+
const total = countAllRuns();
|
|
261
|
+
if (preview.length === 0) {
|
|
262
|
+
console.log(chalk.green(`All jobs have ${keep} or fewer runs. Nothing to prune.`));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
console.log(chalk.bold(`Routine runs to prune (keeping last ${keep} per job)\n`));
|
|
266
|
+
const byJob = new Map();
|
|
267
|
+
for (const run of preview) {
|
|
268
|
+
byJob.set(run.jobName, (byJob.get(run.jobName) || 0) + 1);
|
|
269
|
+
}
|
|
270
|
+
for (const [job, count] of byJob) {
|
|
271
|
+
console.log(` ${chalk.cyan(job)}: ${count} old runs`);
|
|
272
|
+
}
|
|
273
|
+
console.log();
|
|
274
|
+
if (options.dryRun) {
|
|
275
|
+
console.log(chalk.gray(`${preview.length} of ${total} runs would be deleted. Run without --dry-run to delete.`));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!options.yes) {
|
|
279
|
+
if (!isInteractiveTerminal()) {
|
|
280
|
+
console.log(chalk.yellow('Non-interactive shell: pass -y to confirm, or --dry-run to preview.'));
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
let ok = false;
|
|
284
|
+
try {
|
|
285
|
+
ok = await confirm({ message: `Delete ${preview.length} old runs?`, default: false });
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (isPromptCancelled(err)) {
|
|
289
|
+
console.log(chalk.gray('Cancelled'));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
if (!ok) {
|
|
295
|
+
console.log(chalk.gray('Cancelled'));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const { deleted, bytesFreed } = pruneRuns(keep);
|
|
300
|
+
console.log(chalk.green(`Pruned ${deleted} runs (${formatBytes(bytesFreed)}).`));
|
|
301
|
+
}
|
|
75
302
|
async function runOrphanPrune(resourceTypes, options) {
|
|
76
303
|
const groups = collectOrphans(resourceTypes, options.all === true);
|
|
77
304
|
if (groups.length === 0) {
|
|
@@ -133,10 +360,12 @@ async function runOrphanPrune(resourceTypes, options) {
|
|
|
133
360
|
export function registerPruneCommand(program) {
|
|
134
361
|
program
|
|
135
362
|
.command('prune [target]')
|
|
136
|
-
.description('Remove orphan resources
|
|
363
|
+
.description('Remove orphan resources, old versions, trash, sessions, or routine runs')
|
|
137
364
|
.option('--all', 'For orphan cleanup: sweep every installed version (default: current default version per agent)')
|
|
138
|
-
.option('--dry-run', 'Show what would be removed without deleting')
|
|
365
|
+
.option('--dry-run', 'Show what would be removed without deleting (default for state targets)')
|
|
139
366
|
.option('-y, --yes', 'Skip confirmation prompt')
|
|
367
|
+
.option('--older-than <days>', 'For trash/sessions: delete entries older than N days (default: 30d for trash, 90d for sessions)')
|
|
368
|
+
.option('--keep <n>', 'For runs: keep the last N runs per job (default: 10)')
|
|
140
369
|
.addHelpText('after', `
|
|
141
370
|
Targets:
|
|
142
371
|
(none) Orphans across commands, skills, hooks + duplicate versions
|
|
@@ -145,6 +374,9 @@ Targets:
|
|
|
145
374
|
hooks Orphan hook scripts only
|
|
146
375
|
versions Older duplicate version installs only
|
|
147
376
|
<agent> Older duplicate versions for one agent (e.g. 'claude')
|
|
377
|
+
trash Soft-deleted resources older than --older-than days (default 30)
|
|
378
|
+
sessions Session records in sessions.db older than --older-than days (default 90)
|
|
379
|
+
runs Routine execution logs, keeping only --keep per job (default 10)
|
|
148
380
|
|
|
149
381
|
Examples:
|
|
150
382
|
# Full sweep: orphan resources + duplicate versions for current defaults
|
|
@@ -156,14 +388,26 @@ Examples:
|
|
|
156
388
|
# Just version dedup
|
|
157
389
|
agents prune versions
|
|
158
390
|
|
|
159
|
-
# Just version dedup for one agent
|
|
160
|
-
agents prune claude
|
|
161
|
-
|
|
162
391
|
# Sweep every installed version's orphans, not only the defaults
|
|
163
392
|
agents prune --all
|
|
164
393
|
|
|
165
|
-
# Preview
|
|
166
|
-
agents prune --dry-run
|
|
394
|
+
# Preview trash entries older than 30 days
|
|
395
|
+
agents prune trash --dry-run
|
|
396
|
+
|
|
397
|
+
# Delete trash entries older than 60 days
|
|
398
|
+
agents prune trash --older-than 60 -y
|
|
399
|
+
|
|
400
|
+
# Preview session cleanup (90+ days old)
|
|
401
|
+
agents prune sessions --dry-run
|
|
402
|
+
|
|
403
|
+
# Delete sessions older than 180 days
|
|
404
|
+
agents prune sessions --older-than 180 -y
|
|
405
|
+
|
|
406
|
+
# Preview runs cleanup (keeping last 10)
|
|
407
|
+
agents prune runs --dry-run
|
|
408
|
+
|
|
409
|
+
# Keep only the last 5 runs per job
|
|
410
|
+
agents prune runs --keep 5 -y
|
|
167
411
|
|
|
168
412
|
What's an orphan?
|
|
169
413
|
A command, skill, or hook present inside a version home but missing from every
|
|
@@ -171,18 +415,27 @@ What's an orphan?
|
|
|
171
415
|
repos). Usually leftovers from a resource that was deleted or moved but never
|
|
172
416
|
reconciled into the version install.
|
|
173
417
|
|
|
174
|
-
What this does NOT do:
|
|
175
|
-
Adds and updates flow through the auto-sync that runs when you launch the
|
|
176
|
-
agent — there is no manual sync verb.
|
|
177
|
-
|
|
178
418
|
Soft-delete:
|
|
179
419
|
Version directories are NEVER hard-deleted. \`prune\` moves them to
|
|
180
|
-
~/.agents
|
|
181
|
-
|
|
182
|
-
The trash never auto-expires; \`rm -rf\` it manually when you're sure.
|
|
420
|
+
~/.agents/.trash/versions/<agent>/<version>/<timestamp>/. Use
|
|
421
|
+
\`agents prune trash\` to expire old trash entries.
|
|
183
422
|
`)
|
|
184
423
|
.action(async (target, options) => {
|
|
185
424
|
const parsed = parseTarget(target);
|
|
425
|
+
if (parsed.stateType) {
|
|
426
|
+
switch (parsed.stateType) {
|
|
427
|
+
case 'trash':
|
|
428
|
+
await runTrashPrune(options);
|
|
429
|
+
break;
|
|
430
|
+
case 'sessions':
|
|
431
|
+
await runSessionsPrune(options);
|
|
432
|
+
break;
|
|
433
|
+
case 'runs':
|
|
434
|
+
await runRunsPrune(options);
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
186
439
|
if (parsed.resourceTypes.length > 0) {
|
|
187
440
|
await runOrphanPrune(parsed.resourceTypes, options);
|
|
188
441
|
if (parsed.includeVersions)
|