@phnx-labs/agents-cli 1.22.13 → 1.22.15

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 (48) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +4 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +4 -6
  5. package/dist/commands/packages.js +90 -22
  6. package/dist/commands/projects.d.ts +2 -5
  7. package/dist/commands/projects.js +92 -65
  8. package/dist/commands/routines.js +53 -71
  9. package/dist/commands/secrets.js +10 -6
  10. package/dist/commands/sessions-optimize.d.ts +14 -0
  11. package/dist/commands/sessions-optimize.js +41 -0
  12. package/dist/commands/sessions.js +2 -0
  13. package/dist/commands/setup-watchdog.d.ts +3 -0
  14. package/dist/commands/setup-watchdog.js +54 -0
  15. package/dist/commands/setup.js +8 -1
  16. package/dist/commands/ssh.d.ts +11 -0
  17. package/dist/commands/ssh.js +18 -4
  18. package/dist/commands/watchdog.d.ts +3 -4
  19. package/dist/commands/watchdog.js +40 -28
  20. package/dist/lib/auto-dispatch.d.ts +8 -10
  21. package/dist/lib/auto-dispatch.js +20 -42
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  24. package/dist/lib/migrate.d.ts +1 -1
  25. package/dist/lib/migrate.js +4 -3
  26. package/dist/lib/project-import.d.ts +7 -46
  27. package/dist/lib/project-import.js +9 -108
  28. package/dist/lib/projects.d.ts +17 -0
  29. package/dist/lib/projects.js +17 -1
  30. package/dist/lib/registry.d.ts +1 -1
  31. package/dist/lib/registry.js +11 -0
  32. package/dist/lib/routine-activation.d.ts +14 -0
  33. package/dist/lib/routine-activation.js +78 -0
  34. package/dist/lib/routines-project.d.ts +1 -1
  35. package/dist/lib/routines-project.js +3 -9
  36. package/dist/lib/routines.d.ts +3 -1
  37. package/dist/lib/routines.js +32 -19
  38. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  39. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  40. package/dist/lib/session/db.d.ts +19 -0
  41. package/dist/lib/session/db.js +24 -0
  42. package/dist/lib/state.js +17 -4
  43. package/dist/lib/types.d.ts +14 -1
  44. package/dist/lib/watchdog/rotate.d.ts +1 -1
  45. package/dist/lib/watchdog/rotate.js +1 -1
  46. package/package.json +1 -1
  47. package/dist/lib/watchdog/routine.d.ts +0 -44
  48. package/dist/lib/watchdog/routine.js +0 -69
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.15
4
+
5
+ - **Separate routine definitions from device activation (#2023).** Enable a routine by listing its name in `~/.agents/devices/<hostname>/agents.yaml`; built-in Watchdog setup and `watchdog on|off` now update that host-owned manifest without rewriting the routine definition. Source: `apps/cli/src/lib/routine-activation.ts`, `apps/cli/src/commands/setup-watchdog.ts`.
6
+
7
+ - **`agents devices list` no longer shows the "Leased boxes" section by default — it moves behind a new `--all` flag (RUSH-2190).** Loading the section routes through crabbox's bundle auto-detect, which scans the keychain and can raise a macOS Touch ID sheet *after* the device table has printed, hanging non-interactive callers (observed: the `.agents-system` SessionStart topology hook). The default list now renders only registered devices, which are reachable without any secrets; the load/mem/headroom columns are unchanged (the stats probe was already broker-only). `agents devices list --all` restores the section; `--no-stats` remains a hard "instant, no provider calls" opt-out even with `--all`. Source: `apps/cli/src/commands/ssh.ts` (`showLeasedBoxesSection`), `apps/cli/src/commands/ssh.test.ts`.
8
+
9
+ - **Install: `plugin:` prefix on the unified path (Phase 5 packaging).**
10
+ `agents install plugin:<spec>` uses the same grammar and trust gate as
11
+ `agents plugins install` (`name@url`, local path, `--allow-exec-surfaces`).
12
+ Specialized verbs still work; `agents install` is the one add path for mcp,
13
+ skill, plugin, and GitHub sources. Source: `apps/cli/src/commands/packages.ts`,
14
+ `apps/cli/src/lib/registry.ts`.
15
+
16
+ ## 1.22.14
17
+
18
+ - **`agents secrets view <bundle> --reveal` now resolves a locked keychain bundle
19
+ interactively at a real terminal.** The command hardcoded `agentOnly: true` on
20
+ both reveal call sites (`commands/secrets.ts`), so an explicit human `--reveal`
21
+ on a locked bundle went through the broker-only path and errored with an unlock
22
+ hint instead of raising the one Touch ID sheet the human just asked for. The
23
+ `agentOnly` flag is now `isHeadlessSecretsContext() || !isInteractiveTerminal()`
24
+ — under an agent (`AGENTS_RUNTIME`) or with no TTY it stays broker-only and
25
+ never prompts, but a deliberate `--reveal` typed at an interactive terminal
26
+ resolves the value with a single biometric sheet. This mirrors the existing
27
+ `reveal && !isInteractiveTerminal()` guard a few lines up. `export --plaintext`
28
+ and `exec` are untouched — they stay intentionally silent for release/CI
29
+ scripts. Source: `apps/cli/src/commands/secrets.ts`.
30
+
31
+ - **`agents sessions optimize` — compact the FTS5 session search index.** The scanner delete+inserts a session's docs into the `tool_call_text` / `session_text` full-text indexes on every rescan, and FTS5 never merges the resulting segments on its own — so over thousands of sessions the `%_data` shadow tables bloat with hundreds of thousands of unmerged segments (observed on a real fleet box: 701 MB of index for ~69 MB of content, 196K segments) and `agents sessions` slows to a crawl / hangs. The new command runs FTS5 `'optimize'` (merge all segments, purge tombstones), non-destructively — no searchable content is lost. Reclaimed space frees as reusable pages inside the DB file (VACUUM with the daemon stopped returns it to disk); wireable to a weekly routine so the index never re-bloats. Source: `apps/cli/src/lib/session/db.ts` (`optimizeSessionSearchIndex`), `apps/cli/src/commands/sessions-optimize.ts`.
32
+
3
33
  ## 1.22.13
4
34
 
5
35
  - **`agents sessions` accepts direct live-state flags and remains fleet-wide by default.** `--working`, `--idle`, `--waiting`, `--orphan`/`--orphaned`, `--crashed`, `--closed`, `--abandoned`, `--queued`, and `--unknown` each imply the live scan; multiple flags form a union. `--working` is narrower than `--active`: it excludes idle, waiting, and lifecycle-failure rows. Cross-device collection was already the default and stays that way; `--local` opts out, while `--all` continues to widen historical directory and time scope. Source: `apps/cli/src/commands/sessions.ts`, `apps/cli/src/commands/sessions.test.ts`.
package/README.md CHANGED
@@ -396,7 +396,7 @@ agents watchdog --nudge # actually inject "Continue." into the stalled split
396
396
  agents watchdog --watch # daemon loop: a tick every --interval
397
397
  ```
398
398
 
399
- `agents watchdog` detects a stalled session, resolves the *exact* terminal split it lives in (tmux, iTerm, VSCodium, or a raw pty), and injects a nudge -- `Continue.` by default, or set `--text`. It's dry by default; `--nudge` acts on a single tick, and `agents watchdog enable` flips global auto-nudge on so `--watch` injects on its own. Steer a single run with `agents watchdog policy <id> off | keep | handsoff`.
399
+ `agents watchdog` detects a stalled session, resolves the *exact* terminal split it lives in (tmux, iTerm, VSCodium, or a raw pty), and injects a nudge -- `Continue.` by default, or set `--text`. It's dry by default; `--nudge` acts on a single tick, and `agents watchdog on|off` enables or disables the built-in routine on this device. `agents setup watchdog` chooses devices. Steer a single run with `agents watchdog policy <id> off | keep | handsoff`.
400
400
 
401
401
  A stalled session whose tail shows a hard account limit ("You've hit your weekly limit · resets …") is **rotated in place** instead of nudged: the watchdog gates on the same healthy-account selection `agents run auto` makes (zero healthy → one skip event per cooldown window, terminal untouched), injects the harness's exit sequence, relaunches `agents run auto --interactive --session-id <uuid>` in the *same* tab, then replays the old session's resume once the new TUI is live. Default on; `agents watchdog rotate off` disables it (nudging stays on).
402
402
 
@@ -921,11 +921,11 @@ agents routines run daily-digest # Test it now, ignore the schedule
921
921
  agents routines logs daily-digest # Last execution — status + report (add --full for raw stdout)
922
922
  agents routines stats # Run count, failed, missed, avg/p50/p95 duration — per job or all
923
923
 
924
- # Routines sync to every device; restrict to an allowlist with --devices
924
+ # Definitions sync to every device; activation is stored per hostname
925
925
  agents routines add nightly-drain --schedule "0 3 * * *" --agent claude \
926
- --devices yosemite-s0,mac-mini --prompt "Drain the local work queue"
926
+ --prompt "Drain the local work queue"
927
927
 
928
- agents routines devices nightly-drain --set yosemite-s0,mac-mini # update allowlist
928
+ agents routines devices nightly-drain --set yosemite-s0,mac-mini # enable on both hosts
929
929
  agents routines list --host yosemite-s0 # query another device
930
930
 
931
931
  # Signed webhook trigger: Linear issue labeled "agent" fires a routine
package/dist/bin/agents CHANGED
Binary file
@@ -7,8 +7,7 @@ import { resolveProvider, getAllProviders, getDefaultProviderId } from '../lib/c
7
7
  import { insertTask, updateTaskStatus, getTaskById, listTasks as listStoredTasks, listActiveTasks } from '../lib/cloud/store.js';
8
8
  import { renderStream } from '../lib/cloud/stream.js';
9
9
  import { MissingTargetError, MAX_IMAGES_PER_DISPATCH } from '../lib/cloud/types.js';
10
- import { normalizeTriggerEvent, validateTrigger, writeJob, jobExists, GITHUB_TRIGGER_EVENTS } from '../lib/routines.js';
11
- import { machineId } from '../lib/machine-id.js';
10
+ import { normalizeTriggerEvent, validateTrigger, writeJob, setJobEnabled, jobExists, GITHUB_TRIGGER_EVENTS } from '../lib/routines.js';
12
11
  import { emit } from '../lib/events.js';
13
12
  import { shareRuntimeEnv } from '../lib/share/config.js';
14
13
  /** Map a supported image file extension to its wire mimeType. Rejects anything else. */
@@ -310,16 +309,15 @@ Examples:
310
309
  };
311
310
  if (repoValues[0])
312
311
  routine.repo = repoValues[0];
313
- // --host with --on: the webhook-fired run places on that machine (the
314
- // routine carries the placement, and firing pins to THIS device so a
315
- // fleet of receivers can't each dispatch a duplicate).
312
+ // --host controls where the enabled local receiver dispatches the run.
313
+ // Device activation remains in this receiver's device manifest.
316
314
  if (options.host) {
317
315
  routine.host = options.host;
318
316
  if (options.remoteCwd)
319
317
  routine.remoteCwd = options.remoteCwd;
320
- routine.devices = [machineId()];
321
318
  }
322
319
  writeJob(routine);
320
+ setJobEnabled(routine.name, true);
323
321
  if (json) {
324
322
  console.log(JSON.stringify({ ok: true, registered: routineName, trigger }, null, 2));
325
323
  }
@@ -22,7 +22,7 @@ import { discoverWorkflowsFromRepo, installWorkflowCentrally, } from '../lib/wor
22
22
  import { discoverSubagentsFromRepo, installSubagentCentrally, } from '../lib/subagents.js';
23
23
  import { discoverPermissionsFromRepo, installPermissionSet, } from '../lib/permissions.js';
24
24
  import { listInstalledVersions, resolveConfiguredAgentTargets, syncResourcesToVersion, } from '../lib/versions.js';
25
- import { isInteractiveTerminal, isPromptCancelled, parseCommaSeparatedList, requireDestructiveArg, requireInteractiveSelection, resolveInstalledAgentTargetsAutoInstalling, } from './utils.js';
25
+ import { formatPath, isInteractiveTerminal, isPromptCancelled, parseCommaSeparatedList, requireDestructiveArg, requireInteractiveSelection, resolveInstalledAgentTargetsAutoInstalling, } from './utils.js';
26
26
  import { itemPicker } from '../lib/picker.js';
27
27
  import { registerMcpCommandToTargets, discoverMcpConfigsFromRepo, installMcpConfigCentrally, } from '../lib/mcp.js';
28
28
  export function buildMcpPackageCommand(pkg) {
@@ -435,37 +435,30 @@ the sha256 in the index and abort on mismatch.
435
435
  // ==========================================================================
436
436
  program
437
437
  .command('install <identifier>')
438
- .description('Install a package by registry name (mcp:notion), GitHub URL (gh:user/repo), or skill identifier')
438
+ .description('Install a package: mcp:, skill:, plugin:, or GitHub (gh:user/repo) — one install path (Phase 5)')
439
439
  .option('-a, --agents <list>', 'Targets: claude, codex@0.116.0, or cursor@default')
440
440
  .option('--types <list>', 'When source is a repo: comma-separated resource types to install (skills,workflows,commands,hooks,permissions,subagents,mcp)')
441
441
  .option('--names <list>', 'When source is a repo: comma-separated resource names within the selected types')
442
442
  .option('-y, --yes', 'Auto-install any missing agent versions without prompting')
443
+ .option('--allow-exec-surfaces', 'With plugin: allow plugins that ship hooks/mcp/bin (same as agents plugins install)')
443
444
  .addHelpText('after', `
444
- Install resolves the package type (MCP server, skill, command, hook) and installs to the specified agents. Packages can come from registries (mcp:, skill:), GitHub (gh:user/repo), or direct URLs.
445
+ Install is the unified add path (Phase 5 packaging). Prefix the identifier:
446
+
447
+ mcp:<name> MCP server from a registry
448
+ skill:<name> skill from a registry (or gh: fallback)
449
+ plugin:<spec> plugin — same grammar as agents plugins install (name@url or path)
450
+ gh:user/repo clone a DotAgents / multi-resource repo and install selected types
445
451
 
446
452
  Examples:
447
- # Install an MCP server from a registry
448
453
  agents install mcp:notion --agents claude
449
-
450
- # Install skills and commands from GitHub
454
+ agents install skill:animator --agents claude,codex
455
+ agents install plugin:my-plugin@https://github.com/user/my-plugin.git
456
+ agents install plugin:~/Projects/rush-toolkit
451
457
  agents install gh:anthropics/skills --agents codex,claude
452
-
453
- # Install using GitHub shorthand
454
- agents install gh:user/repo --agents claude@2.1.112
455
-
456
- # Install only specific resource types from a multi-resource repo
457
458
  agents install gh:phnx-labs/.agents-system --types skills,workflows --agents claude@all
458
459
 
459
- # Install specific resources by name
460
- agents install gh:phnx-labs/.agents-system --types skills --names animator,composer --agents claude@all
461
-
462
- # Install to all installed agents (uses defaults or prompts)
463
- agents install mcp:postgres
464
-
465
- When to use:
466
- - After search: 'agents search notion' then 'agents install mcp:notion'
467
- - Team setup: 'agents install gh:team/resources' to sync everyone's tooling
468
- - Quick MCP add: 'agents install mcp:<name>' when you know the package name
460
+ Specialized verbs still work (agents plugins install, agents skills add) and
461
+ delegate to the same underlying installers.
469
462
  `)
470
463
  .action(async (identifier, options) => {
471
464
  const spinner = ora('Resolving package...').start();
@@ -473,10 +466,85 @@ When to use:
473
466
  const resolved = await resolvePackage(identifier);
474
467
  if (!resolved) {
475
468
  spinner.fail('Package not found');
476
- console.log(chalk.gray('\nTip: Use explicit prefix (mcp:, skill:, gh:) or check the identifier.'));
469
+ console.log(chalk.gray('\nTip: Use explicit prefix (mcp:, skill:, plugin:, gh:) or check the identifier.'));
477
470
  process.exit(1);
478
471
  }
479
472
  spinner.succeed(`Found ${resolved.type} package`);
473
+ if (resolved.type === 'plugin') {
474
+ spinner.stop();
475
+ const spec = resolved.pluginSpec ?? resolved.source;
476
+ console.log(chalk.gray(`Installing plugin from: ${spec}`));
477
+ const { installPlugin, getPlugin, inspectPluginCapabilities, pluginCapabilityLabels, parseInstallSpec, checkPluginDependencies, hasPluginExecSurfaces, pluginSupportsAgent, syncPluginToVersion, pluginResourceGroups, } = await import('../lib/plugins.js');
478
+ const { listInstalledVersions, getGlobalDefault, getVersionHomePath, syncResourcesToVersion } = await import('../lib/versions.js');
479
+ const { agentLabel } = await import('../lib/agents.js');
480
+ let name;
481
+ let root;
482
+ try {
483
+ const result = await installPlugin(spec);
484
+ name = result.name;
485
+ root = result.root;
486
+ }
487
+ catch (err) {
488
+ console.log(chalk.red(`Install failed: ${err.message}`));
489
+ process.exit(1);
490
+ }
491
+ const plugin = getPlugin(name);
492
+ if (!plugin) {
493
+ console.log(chalk.red(`Installed but could not load plugin '${name}'`));
494
+ process.exit(1);
495
+ }
496
+ const capabilities = inspectPluginCapabilities(root);
497
+ const allowExec = options.allowExecSurfaces === true;
498
+ if (hasPluginExecSurfaces(capabilities) && !allowExec) {
499
+ const source = parseInstallSpec(spec).source;
500
+ console.error(chalk.red('Install refused: plugin ships executable surfaces:'));
501
+ for (const label of pluginCapabilityLabels(capabilities)) {
502
+ console.error(` ${label}`);
503
+ }
504
+ console.error(`Re-run with --allow-exec-surfaces if you trust the source: ${source}@HEAD`);
505
+ fs.rmSync(root, { recursive: true, force: true });
506
+ process.exit(1);
507
+ }
508
+ const missingDeps = checkPluginDependencies(plugin.manifest);
509
+ if (missingDeps.length > 0) {
510
+ console.log(chalk.yellow(`Warning: missing dependencies: ${missingDeps.join(', ')}`));
511
+ console.log(chalk.gray('Install them with: agents install plugin:<name>@<source>'));
512
+ }
513
+ // Same sync loop as `agents plugins install` (default version per harness).
514
+ console.log();
515
+ let synced = 0;
516
+ for (const agentId of capableAgents('plugins')) {
517
+ if (!pluginSupportsAgent(plugin, agentId))
518
+ continue;
519
+ const versions = listInstalledVersions(agentId);
520
+ if (versions.length === 0)
521
+ continue;
522
+ const defaultVer = getGlobalDefault(agentId);
523
+ const targetVersions = defaultVer ? [defaultVer] : [versions[versions.length - 1]];
524
+ for (const version of targetVersions) {
525
+ const didSync = allowExec
526
+ ? syncPluginToVersion(plugin, agentId, getVersionHomePath(agentId, version), {
527
+ allowExecSurfaces: true,
528
+ }).success
529
+ : syncResourcesToVersion(agentId, version, { plugins: [name] }).plugins.length > 0;
530
+ if (didSync) {
531
+ console.log(chalk.green(` Synced to ${agentLabel(agentId)}@${version}`));
532
+ synced++;
533
+ }
534
+ }
535
+ }
536
+ if (synced === 0) {
537
+ console.log(chalk.gray(' No supported agent versions installed — run "agents use <agent>@<version>" to sync.'));
538
+ }
539
+ console.log(chalk.bold(`\nInstalled ${plugin.name} v${plugin.manifest.version} to ${formatPath(root)}`));
540
+ const groups = pluginResourceGroups(plugin);
541
+ if (groups.length > 0) {
542
+ for (const g of groups) {
543
+ console.log(chalk.gray(` ${g.label}: ${g.items.slice(0, 8).join(', ')}${g.items.length > 8 ? '…' : ''}`));
544
+ }
545
+ }
546
+ return;
547
+ }
480
548
  if (resolved.type === 'mcp') {
481
549
  // Install MCP server
482
550
  const entry = resolved.mcpEntry;
@@ -24,11 +24,8 @@ export declare function formatFleetSkippedNote(skipped: string[]): string;
24
24
  *
25
25
  * `stderr: 'ignore'` is load-bearing, not tidiness. A checkout with no origin
26
26
  * makes git print `error: No such remote 'origin'` on ITS stderr, which is the
27
- * terminal's — the catch below never sees it. That was invisible while this was
28
- * called once from `add` inside a real repo, and became noise the moment
29
- * `import --from-factory` started calling it per row: importing 12 registry
30
- * rows printed two raw git errors between the progress lines. Absence of a
31
- * remote is an expected answer here (`undefined`), not something to report.
27
+ * terminal's — the catch below never sees it. Absence of a remote is an
28
+ * expected answer here (`undefined`), not something to report.
32
29
  */
33
30
  export declare function originSlug(cwd: string): string | undefined;
34
31
  /** One `projects list` row, pre-render. */
@@ -20,16 +20,15 @@ import { machineId } from '../lib/machine-id.js';
20
20
  import { getActiveSessions } from '../lib/session/active.js';
21
21
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
22
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
23
- import { factoryProjectsPath } from '../lib/auto-dispatch.js';
24
23
  import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, workspaceWarnings, } from '../lib/project-probe.js';
25
- import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
24
+ import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, validateProjectDef, } from '../lib/projects.js';
26
25
  import { rollupSessionsByProject, withDefaultMachine, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembersByHost, formatProjectWarnings, } from '../lib/project-status.js';
27
26
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
28
27
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
29
28
  import { checkRepoSlug } from '../lib/project-doctor.js';
30
29
  import { formatFocusAreas, readFocusAreas } from '../lib/project-focus.js';
31
30
  import { formatVerdict, scheduleVerdict } from '../lib/project-schedule.js';
32
- import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
31
+ import { buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
33
32
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
34
33
  export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
35
34
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
@@ -73,11 +72,8 @@ function parseGoalFlag(raw) {
73
72
  *
74
73
  * `stderr: 'ignore'` is load-bearing, not tidiness. A checkout with no origin
75
74
  * makes git print `error: No such remote 'origin'` on ITS stderr, which is the
76
- * terminal's — the catch below never sees it. That was invisible while this was
77
- * called once from `add` inside a real repo, and became noise the moment
78
- * `import --from-factory` started calling it per row: importing 12 registry
79
- * rows printed two raw git errors between the progress lines. Absence of a
80
- * remote is an expected answer here (`undefined`), not something to report.
75
+ * terminal's — the catch below never sees it. Absence of a remote is an
76
+ * expected answer here (`undefined`), not something to report.
81
77
  */
82
78
  export function originSlug(cwd) {
83
79
  try {
@@ -92,38 +88,6 @@ export function originSlug(cwd) {
92
88
  return undefined;
93
89
  }
94
90
  }
95
- /**
96
- * Read the Factory registry off disk and plan its import. Every read failure is
97
- * fatal and named — an unreadable or malformed registry must not read as "0
98
- * projects to import".
99
- */
100
- function runFactoryImport(existing, opts) {
101
- const src = factoryProjectsPath();
102
- let rawText;
103
- try {
104
- rawText = fs.readFileSync(src, 'utf8');
105
- }
106
- catch {
107
- console.error(chalk.red(`No Factory registry at ${src}`));
108
- process.exit(1);
109
- }
110
- let parsed;
111
- try {
112
- parsed = JSON.parse(rawText);
113
- }
114
- catch {
115
- console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
116
- process.exit(1);
117
- }
118
- const rows = Array.isArray(parsed)
119
- ? parsed
120
- : Array.isArray(parsed?.projects)
121
- ? parsed.projects
122
- : [];
123
- // The checkout's real `origin` overrides the registry's path-derived slug —
124
- // see buildFactoryImportCandidates for why the path guess is not trustworthy.
125
- return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
126
- }
127
91
  /**
128
92
  * List the workspace's Linear projects and plan their import, binding each to a
129
93
  * local checkout under the configured projects root when the names match
@@ -442,7 +406,11 @@ export function registerProjectsCommands(program) {
442
406
  examples: `
443
407
  agents projects import --from-linear # the projects you actually track
444
408
  agents projects add rush --repo phnx-labs/rush --path apps/web
445
- agents projects list
409
+ agents projects list # definitions only (no session scan)
410
+ agents projects list --with-agents # opt-in local active counts
411
+ agents projects list --json # machine-readable defs (Factory uses this)
412
+ echo '{...}' | agents projects save --json # create/update one def from stdin
413
+ agents projects rm rush --json # machine-readable delete
446
414
  agents projects status # every project, across the whole fleet
447
415
  agents projects status rush # one project (same body as view/show)
448
416
  agents projects view rush # alias of status <name>
@@ -452,31 +420,34 @@ export function registerProjectsCommands(program) {
452
420
  `,
453
421
  notes: `
454
422
  Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
455
- machines with 'agents push/pull'.
456
-
457
- 'import --from-factory' reads Factory's auto-detected registry, which
458
- guesses from checkouts on disk — it imports only 'high' confidence rows by
459
- default. Widen with --min-confidence medium or --all, and drop a bad guess
460
- with 'agents projects rm <name>'.
423
+ machines with 'agents push/pull'. Factory reads and writes only through
424
+ these commands — never ~/.agents/factory/projects.json.
461
425
  `,
462
426
  });
463
427
  // ---- list ----
464
428
  projects
465
429
  .command('list')
466
- .description('List defined projects with their root, repo, and live agent count.')
430
+ .description('List defined projects (definitions only by default; no session scan).')
467
431
  .option('--json', 'Machine-readable output')
432
+ .option('--with-agents', 'Include local active agent counts (opt-in; never SSH)')
468
433
  .action(async (opts) => {
469
434
  const defs = listProjectDefs();
435
+ // Definitions-only by default: zero session scan / SSH. --with-agents is
436
+ // an explicit opt-in for local active counts only (getActiveSessions is
437
+ // local; it never fans out).
438
+ const roll = opts.withAgents
439
+ ? rollupSessionsByProject(defs, await getActiveSessions())
440
+ : undefined;
470
441
  if (opts.json) {
471
- const roll = rollupSessionsByProject(defs, await getActiveSessions());
472
- console.log(JSON.stringify(defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 })), null, 2));
442
+ console.log(JSON.stringify(opts.withAgents
443
+ ? defs.map((d) => ({ ...d, agents: roll.get(d.name)?.agents ?? 0 }))
444
+ : defs, null, 2));
473
445
  return;
474
446
  }
475
447
  if (!defs.length) {
476
448
  console.log(chalk.gray('No projects defined. Add one: agents projects add <name>'));
477
449
  return;
478
450
  }
479
- const roll = rollupSessionsByProject(defs, await getActiveSessions());
480
451
  const rows = defs.map((d) => ({
481
452
  name: d.name,
482
453
  path: truncate(d.root ?? d.defaultPath ?? '', LIST_PATH_MAX),
@@ -484,9 +455,9 @@ export function registerProjectsCommands(program) {
484
455
  }));
485
456
  const w = computeProjectListWidths(rows);
486
457
  for (const [i, d] of defs.entries()) {
487
- const agents = roll.get(d.name)?.agents ?? 0;
488
458
  const row = rows[i];
489
- console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))} ${agents} agents`);
459
+ const agentsSuffix = opts.withAgents && roll ? ` ${roll.get(d.name)?.agents ?? 0} agents` : '';
460
+ console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))}${agentsSuffix}`);
490
461
  }
491
462
  });
492
463
  // ---- add ----
@@ -732,11 +703,8 @@ export function registerProjectsCommands(program) {
732
703
  // ---- import ----
733
704
  projects
734
705
  .command('import')
735
- .description('Import project definitions from Linear (preferred) or the Factory auto-detection registry.')
736
- .option('--from-linear', 'Import the workspace\'s Linear projects (via the `linear` CLI)')
737
- .option('--from-factory', 'Import the Factory registry (~/.agents/factory/projects.json)')
738
- .option('--min-confidence <level>', '--from-factory only: lowest detection confidence to import — low|medium|high (default: high)')
739
- .option('--all', '--from-factory only: import every row regardless of confidence')
706
+ .description('Import project definitions from Linear (via the `linear` CLI).')
707
+ .option('--from-linear', 'Import the workspace\'s Linear projects')
740
708
  .option('--force', 'Overwrite existing definitions')
741
709
  .action((raw) => {
742
710
  let opts;
@@ -748,7 +716,7 @@ export function registerProjectsCommands(program) {
748
716
  process.exit(1);
749
717
  }
750
718
  const existing = new Map(listProjectDefs().map((d) => [d.name, d]));
751
- const result = opts.source === 'linear' ? runLinearImport(existing, opts) : runFactoryImport(existing, opts);
719
+ const result = runLinearImport(existing, opts);
752
720
  for (const def of result.defs)
753
721
  writeProjectDef(def);
754
722
  const n = result.defs.length;
@@ -756,9 +724,6 @@ export function registerProjectsCommands(program) {
756
724
  console.log(chalk.green(`Imported ${n} project${n === 1 ? '' : 's'}${s ? chalk.gray(` (${s} skipped)`) : ''}`));
757
725
  for (const skip of result.skipped)
758
726
  console.log(chalk.gray(` skip ${skip.name}: ${skip.reason}`));
759
- if (opts.source === 'factory' && s > 0 && opts.minConfidence === 'high') {
760
- console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
761
- }
762
727
  });
763
728
  // ---- set ----
764
729
  projects
@@ -883,17 +848,79 @@ export function registerProjectsCommands(program) {
883
848
  writeProjectDef(def);
884
849
  console.log(chalk.green(`${def.name} → Linear project "${p.name}" (${p.id})${p.url ? ` ${p.url}` : ''}`));
885
850
  });
851
+ // ---- save ----
852
+ projects
853
+ .command('save')
854
+ .description('Create or update one project from a complete ProjectDef JSON object on stdin.')
855
+ .option('--json', 'Required: read ProjectDef JSON from stdin; print the saved definition as JSON')
856
+ .action(async (opts) => {
857
+ if (!opts.json) {
858
+ console.error(chalk.red('projects save requires --json (pipe one complete ProjectDef JSON object on stdin).'));
859
+ process.exit(1);
860
+ }
861
+ const chunks = [];
862
+ for await (const c of process.stdin)
863
+ chunks.push(c);
864
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
865
+ if (!raw) {
866
+ console.error(chalk.red('projects save --json: empty stdin (expected one ProjectDef JSON object).'));
867
+ process.exit(1);
868
+ }
869
+ let parsed;
870
+ try {
871
+ parsed = JSON.parse(raw);
872
+ }
873
+ catch (e) {
874
+ console.error(chalk.red(`projects save --json: invalid JSON: ${e instanceof Error ? e.message : String(e)}`));
875
+ process.exit(1);
876
+ }
877
+ let def;
878
+ try {
879
+ def = validateProjectDef(parsed);
880
+ }
881
+ catch (e) {
882
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
883
+ process.exit(1);
884
+ }
885
+ writeProjectDef(def);
886
+ const saved = loadProjectDef(def.name);
887
+ if (!saved) {
888
+ console.error(chalk.red(`projects save: wrote "${def.name}" but could not reload it`));
889
+ process.exit(1);
890
+ }
891
+ console.log(JSON.stringify(saved, null, 2));
892
+ });
886
893
  // ---- rm ----
887
894
  projects
888
895
  .command('rm <name>')
889
896
  .alias('remove')
890
897
  .description('Delete a project definition. Never touches the repo.')
891
- .action((name) => {
898
+ .option('--json', 'Machine-readable success / error')
899
+ .action((name, opts) => {
900
+ if (!isSafeProjectName(name)) {
901
+ if (opts.json) {
902
+ console.log(JSON.stringify({ ok: false, name, error: `Invalid project name: "${name}"` }));
903
+ }
904
+ else {
905
+ console.error(chalk.red(`Invalid project name: "${name}"`));
906
+ }
907
+ process.exit(1);
908
+ }
892
909
  if (removeProjectDef(name)) {
893
- console.log(chalk.green(`Removed project "${name}"`));
910
+ if (opts.json) {
911
+ console.log(JSON.stringify({ ok: true, name, removed: true }));
912
+ }
913
+ else {
914
+ console.log(chalk.green(`Removed project "${name}"`));
915
+ }
894
916
  }
895
917
  else {
896
- console.error(chalk.red(`No project named "${name}".`));
918
+ if (opts.json) {
919
+ console.log(JSON.stringify({ ok: false, name, error: `No project named "${name}"` }));
920
+ }
921
+ else {
922
+ console.error(chalk.red(`No project named "${name}".`));
923
+ }
897
924
  process.exit(1);
898
925
  }
899
926
  });