@phnx-labs/agents-cli 1.20.70 → 1.20.72

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 (80) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/commands.js +2 -0
  6. package/dist/commands/exec.js +12 -0
  7. package/dist/commands/fleet-capture.d.ts +14 -0
  8. package/dist/commands/fleet-capture.js +107 -0
  9. package/dist/commands/fork.js +13 -7
  10. package/dist/commands/hosts.js +11 -0
  11. package/dist/commands/logs.js +38 -9
  12. package/dist/commands/mcp.js +2 -0
  13. package/dist/commands/resource-view.d.ts +2 -0
  14. package/dist/commands/resource-view.js +8 -0
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +24 -4
  17. package/dist/commands/sessions-tail.js +1 -2
  18. package/dist/commands/sessions.d.ts +6 -0
  19. package/dist/commands/sessions.js +14 -6
  20. package/dist/commands/skills.js +2 -0
  21. package/dist/commands/ssh.js +24 -6
  22. package/dist/commands/status.js +8 -2
  23. package/dist/commands/subagents.js +3 -1
  24. package/dist/commands/uninstall.d.ts +11 -0
  25. package/dist/commands/uninstall.js +158 -0
  26. package/dist/commands/usage.d.ts +13 -0
  27. package/dist/commands/usage.js +50 -28
  28. package/dist/commands/utils.d.ts +29 -0
  29. package/dist/commands/utils.js +24 -0
  30. package/dist/commands/versions.js +120 -11
  31. package/dist/index.js +5 -3
  32. package/dist/lib/commands.d.ts +10 -0
  33. package/dist/lib/commands.js +31 -7
  34. package/dist/lib/daemon.d.ts +9 -0
  35. package/dist/lib/daemon.js +43 -0
  36. package/dist/lib/devices/connect.d.ts +13 -0
  37. package/dist/lib/devices/connect.js +20 -0
  38. package/dist/lib/devices/fleet.d.ts +36 -3
  39. package/dist/lib/devices/fleet.js +42 -4
  40. package/dist/lib/devices/sync.d.ts +30 -0
  41. package/dist/lib/devices/sync.js +50 -0
  42. package/dist/lib/doctor-diff.js +38 -16
  43. package/dist/lib/fleet/apply.d.ts +4 -0
  44. package/dist/lib/fleet/apply.js +28 -5
  45. package/dist/lib/fleet/capture.d.ts +35 -0
  46. package/dist/lib/fleet/capture.js +51 -0
  47. package/dist/lib/fleet/manifest.d.ts +6 -0
  48. package/dist/lib/fleet/manifest.js +24 -1
  49. package/dist/lib/fleet/types.d.ts +24 -1
  50. package/dist/lib/git.d.ts +14 -0
  51. package/dist/lib/git.js +39 -1
  52. package/dist/lib/hosts/logs.d.ts +12 -0
  53. package/dist/lib/hosts/logs.js +18 -0
  54. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  55. package/dist/lib/menubar/install-menubar.js +26 -13
  56. package/dist/lib/secrets/agent.d.ts +5 -0
  57. package/dist/lib/secrets/agent.js +35 -3
  58. package/dist/lib/secrets/bundles.js +28 -0
  59. package/dist/lib/secrets/index.d.ts +14 -0
  60. package/dist/lib/secrets/index.js +36 -5
  61. package/dist/lib/secrets/session-store.d.ts +90 -0
  62. package/dist/lib/secrets/session-store.js +221 -0
  63. package/dist/lib/session/render.d.ts +9 -1
  64. package/dist/lib/session/render.js +35 -4
  65. package/dist/lib/share/capture.d.ts +2 -0
  66. package/dist/lib/share/capture.js +12 -5
  67. package/dist/lib/shims.d.ts +27 -0
  68. package/dist/lib/shims.js +29 -3
  69. package/dist/lib/skills.d.ts +8 -1
  70. package/dist/lib/skills.js +11 -1
  71. package/dist/lib/startup/command-registry.d.ts +1 -0
  72. package/dist/lib/startup/command-registry.js +2 -0
  73. package/dist/lib/types.d.ts +5 -1
  74. package/dist/lib/uninstall.d.ts +84 -0
  75. package/dist/lib/uninstall.js +347 -0
  76. package/dist/lib/usage.d.ts +13 -1
  77. package/dist/lib/usage.js +32 -14
  78. package/dist/lib/versions.d.ts +16 -0
  79. package/dist/lib/versions.js +40 -3
  80. package/package.json +1 -1
@@ -94,6 +94,39 @@ export function runOnDevice(device, cmd, opts = {}) {
94
94
  };
95
95
  }
96
96
  }
97
+ /**
98
+ * Run `cmd` on THIS machine directly — no ssh. Used by {@link runFleet} for the
99
+ * self target: a box frequently can't ssh to itself (no self-authorized key, as
100
+ * `agents fleet update` hit trying to reach zion from zion) and doesn't need to —
101
+ * `agents upgrade` etc. runs identically as a local process. Mirrors
102
+ * {@link runOnDevice}'s return shape and never throws. The argv is space-joined
103
+ * and evaluated by a shell — matching the POSIX-shell ssh path (so PATH-resolved
104
+ * `agents`, quoting, and `;`/`&&` behave the same). It does NOT replicate the
105
+ * powershell-device encoding runOnDevice uses (`connect.ts` base64 path): a
106
+ * Windows self runs under the default OS shell (cmd.exe), which still resolves
107
+ * `agents` on PATH for the only self commands that matter (`agents upgrade …`).
108
+ */
109
+ export function runLocalCommand(cmd, opts = {}) {
110
+ try {
111
+ const res = spawnSync(cmd.join(' '), {
112
+ shell: true,
113
+ encoding: 'utf-8',
114
+ timeout: opts.timeoutMs ?? 600_000,
115
+ });
116
+ return {
117
+ code: res.status,
118
+ stdout: res.stdout?.toString() ?? '',
119
+ stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
120
+ };
121
+ }
122
+ catch (err) {
123
+ return {
124
+ code: 1,
125
+ stdout: '',
126
+ stderr: err instanceof Error ? err.message : String(err),
127
+ };
128
+ }
129
+ }
97
130
  /**
98
131
  * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
99
132
  * Rejects anything that is not a plain npm version/tag token so a version pin
@@ -110,10 +143,14 @@ export function upgradeCommand(version) {
110
143
  }
111
144
  /**
112
145
  * Execute a command across planned targets. Pure orchestration over
113
- * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
114
- * the runner are recorded as `failed` so one bad device never aborts the rest.
146
+ * {@link runOnDevice} / {@link runLocalCommand}; testable by injecting either.
147
+ * The `self` target runs locally so `agents fleet update` upgrades this machine
148
+ * too instead of failing to ssh to itself. Per-device throws from a runner are
149
+ * recorded as `failed` so one bad device never aborts the rest.
115
150
  */
116
- export function runFleet(targets, cmd, runner = runOnDevice) {
151
+ export function runFleet(targets, cmd, opts = {}) {
152
+ const runner = opts.runner ?? runOnDevice;
153
+ const localRunner = opts.localRunner ?? runLocalCommand;
117
154
  const results = [];
118
155
  for (const t of targets) {
119
156
  if (t.skip) {
@@ -126,7 +163,8 @@ export function runFleet(targets, cmd, runner = runOnDevice) {
126
163
  continue;
127
164
  }
128
165
  try {
129
- const res = runner(t.device, cmd);
166
+ const isSelf = opts.self !== undefined && t.device.name === opts.self;
167
+ const res = isSelf ? localRunner(cmd) : runner(t.device, cmd);
130
168
  const ok = res.code === 0;
131
169
  const detail = (res.stderr || res.stdout).trim().slice(0, 200);
132
170
  results.push({
@@ -69,6 +69,36 @@ export declare function runDeviceSync(opts?: {
69
69
  soft?: boolean;
70
70
  mode?: DeviceSyncMode;
71
71
  }): Promise<DeviceSyncResult>;
72
+ export interface EnsureDevicesResult {
73
+ /** Names newly resolved from Tailscale and upserted into the registry. */
74
+ registered: string[];
75
+ /** Names that could not be resolved (not on the tailnet / tailscale absent). */
76
+ unresolved: string[];
77
+ }
78
+ /**
79
+ * Pure decision for `ensureDevicesRegistered`: split the wanted names into those
80
+ * to resolve+register (missing from the registry, present on the tailnet, not
81
+ * ignored) vs. unresolved (missing and either off-tailnet or ignored). Already-
82
+ * registered names need nothing. Pure so the bootstrap's filter matrix is
83
+ * unit-testable without a tailnet or registry writes.
84
+ */
85
+ export interface WantedPartition {
86
+ toRegister: string[];
87
+ unresolved: string[];
88
+ }
89
+ export declare function partitionWantedDevices(wanted: string[], registered: Set<string>, tailscaleNames: Set<string>, ignored: Set<string>): WantedPartition;
90
+ /**
91
+ * Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
92
+ * manifest wants, register any that aren't in the local registry by resolving
93
+ * them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
94
+ * names only, never IPs/usernames) reconstructs its roster with zero committed
95
+ * connection details. Soft by design: a missing tailscale binary or an offline
96
+ * name yields `unresolved` rather than throwing, so `apply` degrades to "these
97
+ * devices aren't reachable yet" instead of aborting. Reuses the same
98
+ * parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
99
+ * is identical to a synced one.
100
+ */
101
+ export declare function ensureDevicesRegistered(wantedNames: string[]): Promise<EnsureDevicesResult>;
72
102
  /**
73
103
  * The register/remove/ignore decision for the interactive curation picker.
74
104
  * Pure so the highest-risk reconcile logic is unit-testable without a tailnet
@@ -125,6 +125,56 @@ export async function runDeviceSync(opts = {}) {
125
125
  throw err;
126
126
  }
127
127
  }
128
+ export function partitionWantedDevices(wanted, registered, tailscaleNames, ignored) {
129
+ const out = { toRegister: [], unresolved: [] };
130
+ for (const name of wanted) {
131
+ if (registered.has(name))
132
+ continue;
133
+ if (tailscaleNames.has(name) && !ignored.has(name))
134
+ out.toRegister.push(name);
135
+ else
136
+ out.unresolved.push(name);
137
+ }
138
+ return out;
139
+ }
140
+ /**
141
+ * Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
142
+ * manifest wants, register any that aren't in the local registry by resolving
143
+ * them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
144
+ * names only, never IPs/usernames) reconstructs its roster with zero committed
145
+ * connection details. Soft by design: a missing tailscale binary or an offline
146
+ * name yields `unresolved` rather than throwing, so `apply` degrades to "these
147
+ * devices aren't reachable yet" instead of aborting. Reuses the same
148
+ * parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
149
+ * is identical to a synced one.
150
+ */
151
+ export async function ensureDevicesRegistered(wantedNames) {
152
+ const registryBefore = await loadDevices();
153
+ const registered = new Set(Object.keys(registryBefore));
154
+ const missing = wantedNames.filter((n) => !registered.has(n));
155
+ if (missing.length === 0)
156
+ return { registered: [], unresolved: [] };
157
+ let nodes;
158
+ try {
159
+ nodes = parseTailscaleStatus(tailscaleStatusJson());
160
+ }
161
+ catch {
162
+ // Tailscale absent/unreachable — nothing resolvable; report all missing.
163
+ return { registered: [], unresolved: missing };
164
+ }
165
+ const ignored = await loadIgnored();
166
+ const tailscaleNames = new Set(nodes.map((n) => n.name));
167
+ const { toRegister, unresolved } = partitionWantedDevices(missing, registered, tailscaleNames, ignored);
168
+ const byName = new Map(nodes.map((n) => [n.name, n]));
169
+ const localUser = localLoginUser();
170
+ const done = [];
171
+ for (const name of toRegister) {
172
+ const input = withDefaultUser(nodeToDeviceInput(byName.get(name)), registryBefore[name]?.user, localUser);
173
+ await upsertDevice(name, input);
174
+ done.push(name);
175
+ }
176
+ return { registered: done, unresolved };
177
+ }
128
178
  export function planDeviceReconciliation(allNames, keep, registered, ignored) {
129
179
  const keepSet = new Set(keep);
130
180
  const regSet = new Set(registered);
@@ -22,21 +22,19 @@
22
22
  import * as fs from 'fs';
23
23
  import * as path from 'path';
24
24
  import { AGENTS, agentConfigDirName } from './agents.js';
25
- import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, } from './state.js';
25
+ import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, getActiveRulesPreset, } from './state.js';
26
+ import { composeRulesFromState } from './rules/compose.js';
26
27
  import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, compareVersions, } from './versions.js';
27
28
  import { discoverPlugins, marketplaceSpecForName } from './plugins.js';
28
29
  import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace.js';
29
30
  import { markdownToToml } from './convert.js';
30
- import { resolveImports, supportsRulesImports } from './rules/compile.js';
31
- import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
31
+ import { listCommandsInVersionHome, getVersionCommandsDir, listPluginCommandNames } from './commands.js';
32
32
  import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
33
33
  import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
34
34
  import { supports } from './capabilities.js';
35
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
36
36
  import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
37
37
  const RULES_DOC_FILENAME = 'README.md';
38
- const COMPILED_HEADER = '<!-- Auto-compiled by agents-cli from ~/.agents/rules/AGENTS.md + imports.\n' +
39
- ' Edit the source files under ~/.agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
40
38
  const ALL_KINDS = [
41
39
  'commands',
42
40
  'skills',
@@ -173,9 +171,16 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
173
171
  homePath,
174
172
  });
175
173
  }
174
+ // Plugin-bundled commands (installed as `<plugin>-<cmd>`) are source-managed by
175
+ // their plugin, tracked under the `plugins` kind — not extras. Without this,
176
+ // `agents doctor` shows every plugin command (swarm-plan, code-review, …) as an
177
+ // unmanaged extra, mirroring the orphan false-positive the prune path had.
178
+ const pluginCommands = listPluginCommandNames();
176
179
  for (const name of installed) {
177
180
  if (seen.has(name))
178
181
  continue;
182
+ if (pluginCommands.has(name))
183
+ continue;
179
184
  const extraHome = asSkill
180
185
  ? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
181
186
  : agent === 'goose'
@@ -236,6 +241,14 @@ function dirsContentMatch(src, dst) {
236
241
  return true;
237
242
  }
238
243
  function diffSkills(agent, version, cwd, excludeProject = false) {
244
+ // Native ~/.agents/skills consumers (Gemini, …) read central skills directly.
245
+ // The orchestrator DELETES their version-home skills dir (syncResourcesToVersion)
246
+ // and registers no skills writer, so there is nothing in the version home to
247
+ // reconcile. Mirror diffVersionSkills' native gate (skills.ts) — without it
248
+ // every central skill is false-reported `missing` and held as unreconcilable
249
+ // forever (drift never clears for these agents).
250
+ if (AGENTS[agent].nativeAgentsSkillsDir)
251
+ return [];
239
252
  const homeDir = getVersionSkillsDir(agent, version);
240
253
  const installed = new Set(listSkillsInVersionHome(agent, version));
241
254
  const layerBases = buildLayerBases(cwd, 'skills', { excludeProject });
@@ -380,18 +393,27 @@ function listRulesNames(cwd, excludeProject = false) {
380
393
  }
381
394
  return out;
382
395
  }
383
- function expectedRuleContent(agent, name, sourcePath) {
384
- // AGENTS.md on agents without native @-import support is compiled with imports inlined.
385
- if (name === 'AGENTS' && !supportsRulesImports(agent)) {
386
- const root = readSafe(sourcePath);
387
- if (root == null)
396
+ function expectedRuleContent(agent, name, version, sourcePath) {
397
+ // The instruction file (AGENTS the agent's CLAUDE.md/GEMINI.md/AGENTS.md)
398
+ // is COMPOSED from `subrules/` fragments for the version's active preset
399
+ // that is exactly what the rules writer emits (see
400
+ // staleness/writers/rules.ts composeRulesFromState). Compare against the
401
+ // same rendering so a correctly-synced home file reconciles instead of being
402
+ // held forever against the raw `rules/AGENTS.md` (the whole-repo doc, which a
403
+ // preset composition deliberately never equals — system subrules don't
404
+ // auto-append). The `agent` is not part of the rendering (every capable agent
405
+ // gets identical composed bytes); it is kept for symmetry with the writer's
406
+ // per-agent dispatch and future per-agent presets.
407
+ if (name === 'AGENTS') {
408
+ try {
409
+ return composeRulesFromState({ preset: getActiveRulesPreset(agent, version) }).content;
410
+ }
411
+ catch {
412
+ // No rules.yaml / unknown preset — the writer skips too; treat as unknown.
388
413
  return null;
389
- // Compile relative to the source's own dir so project-layer AGENTS.md resolves
390
- // its imports relative to the project rules dir, not the user one.
391
- const baseDir = path.dirname(sourcePath);
392
- const { content } = resolveImports(root, baseDir);
393
- return COMPILED_HEADER + content;
414
+ }
394
415
  }
416
+ // Any sibling top-level rules file syncs as a raw copy.
395
417
  return readSafe(sourcePath);
396
418
  }
397
419
  function diffRules(agent, version, cwd, excludeProject = false) {
@@ -418,7 +440,7 @@ function diffRules(agent, version, cwd, excludeProject = false) {
418
440
  rows.push({ kind: 'rules', name, status: 'missing', source: src.layer, sourcePath: src.path });
419
441
  continue;
420
442
  }
421
- const expected = expectedRuleContent(agent, name, src.path);
443
+ const expected = expectedRuleContent(agent, name, version, src.path);
422
444
  const actual = readSafe(homePath);
423
445
  if (expected == null || actual == null) {
424
446
  rows.push({ kind: 'rules', name, status: 'diff', source: src.layer, sourcePath: src.path, homePath });
@@ -30,6 +30,10 @@ export interface DiffContext {
30
30
  /** agents-cli version the source is on — the fleet target version. */
31
31
  targetCliVersion: string;
32
32
  sourceAuth: SourceAuth;
33
+ /** Secrets-bundle names the profile declares. Values are keychain-local and
34
+ * can't be pushed, so each reachable device surfaces them as a manual recreate
35
+ * (`needs-secret`) — informational, never an executed mutation. */
36
+ secretsBundles?: string[];
33
37
  }
34
38
  /** Pure: desired vs probed -> per-device diff + flat action list. */
35
39
  export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
@@ -44,6 +44,7 @@ export function diffFleet(desired, probes, ctx) {
44
44
  };
45
45
  const rowActions = [];
46
46
  const loginBlocked = [];
47
+ const secretsNeeded = [];
47
48
  if (probe.reachable) {
48
49
  // agents-cli presence.
49
50
  if (!probe.cliVersion) {
@@ -82,8 +83,18 @@ export function diffFleet(desired, probes, ctx) {
82
83
  }
83
84
  }
84
85
  }
86
+ // secrets — surfaced, never pushed (values are keychain-local). Declared
87
+ // once at the manifest level, so every reachable device gets the same
88
+ // manual-recreate reminder. Not an executable action (see idempotence
89
+ // check in commands/apply.ts, which excludes needs-* kinds).
90
+ if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
91
+ for (const bundle of ctx.secretsBundles) {
92
+ secretsNeeded.push(bundle);
93
+ rowActions.push({ device: d.device, kind: 'needs-secret', detail: `recreate secrets bundle '${bundle}' (\`agents secrets create ${bundle}\`)` });
94
+ }
95
+ }
85
96
  }
86
- devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked });
97
+ devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked, secretsNeeded });
87
98
  actions.push(...rowActions);
88
99
  }
89
100
  return { devices, actions };
@@ -161,11 +172,18 @@ export function reconcileDevice(row, device, ctx) {
161
172
  steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
162
173
  ok = ok && r.code === 0;
163
174
  }
164
- // 3. config sync.
175
+ // 3. config sync — one `agents sync <scope>` per declared scope. Each scope is
176
+ // a positional repo target (system/user/project/alias); a bare `agents sync`
177
+ // would ignore the profile's declared scopes entirely.
165
178
  if (row.actions.some((a) => a.kind === 'sync-config')) {
166
- const r = sshAgents(['sync']);
167
- steps.push({ kind: 'sync-config', ok: r.code === 0, detail: `sync config` });
168
- ok = ok && r.code === 0;
179
+ const scopes = row.desired.sync.length > 0 ? row.desired.sync : [''];
180
+ let syncOk = true;
181
+ for (const scope of scopes) {
182
+ const r = sshAgents(scope ? ['sync', scope] : ['sync']);
183
+ syncOk = syncOk && r.code === 0;
184
+ }
185
+ steps.push({ kind: 'sync-config', ok: syncOk, detail: `sync config (${row.desired.sync.join(', ') || 'default'})` });
186
+ ok = ok && syncOk;
169
187
  }
170
188
  // 4. login propagation — one bundle for all pushable agents on this device.
171
189
  const pushAgents = [...new Set(row.actions.filter((a) => a.kind === 'push-login').map((a) => a.agent))];
@@ -182,6 +200,11 @@ export function reconcileDevice(row, device, ctx) {
182
200
  for (const blocked of row.loginBlocked) {
183
201
  steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
184
202
  }
203
+ // Surface declared secrets bundles as (non-fatal) manual-recreate reminders —
204
+ // values are keychain-local, never captured or pushed.
205
+ for (const bundle of row.secretsNeeded) {
206
+ steps.push({ kind: 'needs-secret', ok: false, detail: `secrets bundle '${bundle}' must exist on ${row.device} (\`agents ssh ${row.device} -- secrets create ${bundle}\`)` });
207
+ }
185
208
  return { device: row.device, ok, steps };
186
209
  }
187
210
  /** Run a pool of async tasks with a concurrency cap, preserving input order. */
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Serialize the live environment into a `fleet:` manifest.
3
+ *
4
+ * `captureFleet` is PURE — it takes the previous manifest plus already-gathered
5
+ * inputs (device names, per-device agent specs, browser profiles, secret-bundle
6
+ * names, routine names) and returns the new manifest. All I/O (registry read,
7
+ * `agents secrets`/`routines` enumeration, YAML write) happens in the command
8
+ * (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
9
+ * NAMES ONLY, never IPs/usernames — trivially unit-testable.
10
+ *
11
+ * Additive by design: it merges OVER an existing manifest and never clobbers a
12
+ * hand-authored per-device override (`agents:` you set by hand wins over a
13
+ * captured one). The captured roster reflects live state, so it becomes the
14
+ * source of truth for WHICH devices exist.
15
+ */
16
+ import type { FleetManifest, FleetDefaults } from './types.js';
17
+ export interface CaptureInputs {
18
+ /** Registered, non-control device names to record (the roster — names only). */
19
+ devices: string[];
20
+ /** Optional per-device agent specs (e.g. from `--from-pins`), keyed by name. */
21
+ agentsByDevice?: Record<string, string[]>;
22
+ /** Fleet defaults to seed when the manifest has none (source's own agents). */
23
+ defaults?: FleetDefaults;
24
+ /** Secrets-bundle NAMES to ensure exist (values stay in the keychain). */
25
+ secretsBundles?: string[];
26
+ /** Routine NAMES that should be active on the fleet. */
27
+ routines?: string[];
28
+ }
29
+ /**
30
+ * Build the new `fleet:` manifest from the previous one and captured inputs.
31
+ * Pure — no SSH, no filesystem, no registry. The returned object carries device
32
+ * names + desired state only; a caller that serializes it to YAML can assert no
33
+ * address/username ever appears.
34
+ */
35
+ export declare function captureFleet(prev: FleetManifest | undefined, inputs: CaptureInputs): FleetManifest;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Serialize the live environment into a `fleet:` manifest.
3
+ *
4
+ * `captureFleet` is PURE — it takes the previous manifest plus already-gathered
5
+ * inputs (device names, per-device agent specs, browser profiles, secret-bundle
6
+ * names, routine names) and returns the new manifest. All I/O (registry read,
7
+ * `agents secrets`/`routines` enumeration, YAML write) happens in the command
8
+ * (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
9
+ * NAMES ONLY, never IPs/usernames — trivially unit-testable.
10
+ *
11
+ * Additive by design: it merges OVER an existing manifest and never clobbers a
12
+ * hand-authored per-device override (`agents:` you set by hand wins over a
13
+ * captured one). The captured roster reflects live state, so it becomes the
14
+ * source of truth for WHICH devices exist.
15
+ */
16
+ /**
17
+ * Build the new `fleet:` manifest from the previous one and captured inputs.
18
+ * Pure — no SSH, no filesystem, no registry. The returned object carries device
19
+ * names + desired state only; a caller that serializes it to YAML can assert no
20
+ * address/username ever appears.
21
+ */
22
+ export function captureFleet(prev, inputs) {
23
+ const prevDevices = prev && prev.devices !== 'all' && typeof prev.devices === 'object'
24
+ ? prev.devices
25
+ : {};
26
+ // Roster: explicit map of the captured names. A hand-authored override for a
27
+ // device that still exists is preserved; a captured agent list only fills in
28
+ // when the manifest didn't already pin one for that device.
29
+ const devices = {};
30
+ for (const name of inputs.devices) {
31
+ const prevOverride = prevDevices[name] ?? {};
32
+ const override = { ...prevOverride };
33
+ const captured = inputs.agentsByDevice?.[name];
34
+ if (override.agents === undefined && captured && captured.length > 0) {
35
+ override.agents = captured;
36
+ }
37
+ devices[name] = override;
38
+ }
39
+ const manifest = {
40
+ // Keep hand-authored defaults; otherwise seed from the source snapshot.
41
+ defaults: prev?.defaults ?? inputs.defaults ?? {},
42
+ devices,
43
+ };
44
+ const bundles = inputs.secretsBundles ?? prev?.secrets?.bundles;
45
+ if (bundles && bundles.length > 0)
46
+ manifest.secrets = { bundles: [...bundles].sort() };
47
+ const routines = inputs.routines ?? prev?.routines;
48
+ if (routines && routines.length > 0)
49
+ manifest.routines = [...routines].sort();
50
+ return manifest;
51
+ }
@@ -19,6 +19,12 @@ export interface ResolveContext {
19
19
  registeredDevices: string[];
20
20
  /** The source machine, always excluded from the target set. */
21
21
  source: string;
22
+ /** Names the bootstrap could not resolve from Tailscale (off-tailnet, ignored,
23
+ * or a typo). These are SKIPPED with the caller's warning rather than aborting
24
+ * the whole reconcile — a manifest naming an asleep laptop must not hard-fail
25
+ * every other device. Without a bootstrap, this is empty and an unregistered
26
+ * name still throws (genuine misconfig, caught early). */
27
+ unresolved?: string[];
22
28
  }
23
29
  /**
24
30
  * Expand a manifest into concrete per-device desired states. `devices: all`
@@ -66,7 +66,25 @@ export function parseFleetManifest(raw) {
66
66
  else {
67
67
  throw new Error(`fleet: devices must be the string 'all' or a mapping of device -> overrides (got ${JSON.stringify(o.devices)}).`);
68
68
  }
69
- return { defaults, devices };
69
+ const manifest = { defaults, devices };
70
+ // Additive, backward-compatible extras (captured by `agents fleet capture`).
71
+ if (o.secrets !== undefined) {
72
+ if (typeof o.secrets !== 'object' || o.secrets === null || Array.isArray(o.secrets)) {
73
+ throw new Error('fleet: secrets must be a mapping with a `bundles:` list.');
74
+ }
75
+ const bundles = o.secrets.bundles;
76
+ if (bundles !== undefined && !isStringArray(bundles)) {
77
+ throw new Error('fleet: secrets.bundles must be a list of bundle names (e.g. [attio]).');
78
+ }
79
+ manifest.secrets = { bundles: bundles };
80
+ }
81
+ if (o.routines !== undefined) {
82
+ if (!isStringArray(o.routines)) {
83
+ throw new Error('fleet: routines must be a list of routine names.');
84
+ }
85
+ manifest.routines = o.routines;
86
+ }
87
+ return manifest;
70
88
  }
71
89
  /** Read a YAML file and extract + validate its `fleet:` block. */
72
90
  export function readFleetFile(filePath) {
@@ -115,9 +133,14 @@ export function resolveDesired(manifest, ctx) {
115
133
  }
116
134
  return out;
117
135
  }
136
+ const unresolved = new Set(ctx.unresolved ?? []);
118
137
  for (const [name, override] of Object.entries(manifest.devices)) {
119
138
  if (name === ctx.source)
120
139
  continue;
140
+ // Bootstrap couldn't register this name (off-tailnet / ignored / typo) —
141
+ // skip it (the caller already surfaced it) instead of aborting the run.
142
+ if (unresolved.has(name))
143
+ continue;
121
144
  if (!ctx.registeredDevices.includes(name)) {
122
145
  throw new Error(`fleet: device '${name}' is not a registered device. Run \`agents devices add ${name}\` or fix the manifest.`);
123
146
  }
@@ -42,6 +42,23 @@ export interface FleetDeviceOverride {
42
42
  export interface FleetManifest {
43
43
  defaults?: FleetDefaults;
44
44
  devices: 'all' | Record<string, FleetDeviceOverride>;
45
+ /**
46
+ * Fleet-wide extras captured by `agents fleet capture` so a fresh machine can
47
+ * reconstruct the whole environment, not just installed agents. All additive,
48
+ * portable, and LEAK-FREE — names only, never connection details.
49
+ *
50
+ * (Browser profiles are deliberately NOT captured here: the central `browser:`
51
+ * block already syncs verbatim via `agents repo push/pull`, so duplicating it
52
+ * would be redundant — and its ssh:// endpoints can carry `user@host`, which
53
+ * must never be copied into a second location.)
54
+ */
55
+ /** Secrets-bundle NAMES to ensure exist — values live in the keychain and are
56
+ * never captured or pushed; `apply` surfaces missing bundles to recreate. */
57
+ secrets?: {
58
+ bundles?: string[];
59
+ };
60
+ /** Routine NAMES that should be active on the fleet (files sync via the repo). */
61
+ routines?: string[];
45
62
  }
46
63
  /**
47
64
  * A device's desired state after merging defaults with its override and
@@ -74,7 +91,10 @@ export interface DeviceProbe {
74
91
  note?: string;
75
92
  }
76
93
  /** One planned action against a device, in a single reconcile dimension. */
77
- export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login';
94
+ export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
95
+ /** Secrets bundles the profile declares but that can't be pushed (values are
96
+ * keychain-local) — surfaced as a manual recreate, like `needs-login`. */
97
+ | 'needs-secret';
78
98
  export interface FleetAction {
79
99
  device: string;
80
100
  kind: FleetActionKind;
@@ -101,6 +121,9 @@ export interface DeviceDiff {
101
121
  /** Agents that must be logged in on the device but can't be propagated
102
122
  * (source token is device-bound, e.g. macOS keychain). Surfaced, not faked. */
103
123
  loginBlocked: string[];
124
+ /** Secrets-bundle names the profile declares that must be recreated on the
125
+ * device (values are keychain-local — never captured or pushed). Surfaced. */
126
+ secretsNeeded: string[];
104
127
  }
105
128
  /** A portable auth file captured from a source agent home, ready to propagate. */
106
129
  export interface AuthFilePayload {
package/dist/lib/git.d.ts CHANGED
@@ -219,6 +219,20 @@ export declare function displayHomePath(dir: string): string;
219
219
  * branch reconciles instead of failing with "Need to specify how to reconcile
220
220
  * divergent branches".
221
221
  */
222
+ /**
223
+ * Auto-commit the machine's OWN generated per-device meta file
224
+ * (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
225
+ *
226
+ * That file is committed + synced (so every machine can introspect every other
227
+ * machine's pins), but each box rewrites its own copy whenever a pin changes —
228
+ * leaving the tree perpetually dirty and wedging `agents repo pull` (which
229
+ * refuses a dirty tree). This durably commits just that one path (explicit
230
+ * pathspec) so the pull can proceed; genuine user edits to OTHER files are left
231
+ * untouched and still (correctly) block the pull. No-op when the meta path isn't
232
+ * inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
233
+ * path, or null. `metaAbs` is injectable for tests; defaults to the live path.
234
+ */
235
+ export declare function commitOwnDeviceMeta(dir: string, metaAbs?: string): Promise<string | null>;
222
236
  export declare function pullRepo(dir: string): Promise<{
223
237
  success: boolean;
224
238
  commit: string;
package/dist/lib/git.js CHANGED
@@ -10,7 +10,7 @@ import * as fs from 'fs';
10
10
  import * as os from 'os';
11
11
  import * as path from 'path';
12
12
  import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
13
- import { getPackageLocalPath } from './state.js';
13
+ import { getPackageLocalPath, getDeviceMetaPath } from './state.js';
14
14
  import { DEFAULT_SYSTEM_REPO, systemRepoSlug } from './types.js';
15
15
  /**
16
16
  * Validate that a clone/pull source uses a safe git transport before it is
@@ -733,9 +733,47 @@ export function displayHomePath(dir) {
733
733
  * branch reconciles instead of failing with "Need to specify how to reconcile
734
734
  * divergent branches".
735
735
  */
736
+ /**
737
+ * Auto-commit the machine's OWN generated per-device meta file
738
+ * (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
739
+ *
740
+ * That file is committed + synced (so every machine can introspect every other
741
+ * machine's pins), but each box rewrites its own copy whenever a pin changes —
742
+ * leaving the tree perpetually dirty and wedging `agents repo pull` (which
743
+ * refuses a dirty tree). This durably commits just that one path (explicit
744
+ * pathspec) so the pull can proceed; genuine user edits to OTHER files are left
745
+ * untouched and still (correctly) block the pull. No-op when the meta path isn't
746
+ * inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
747
+ * path, or null. `metaAbs` is injectable for tests; defaults to the live path.
748
+ */
749
+ export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
750
+ const resolvedDir = path.resolve(dir);
751
+ const resolvedMeta = path.resolve(metaAbs);
752
+ if (resolvedMeta !== resolvedDir && !resolvedMeta.startsWith(resolvedDir + path.sep)) {
753
+ return null; // meta lives outside this repo — not ours to commit here
754
+ }
755
+ const rel = path.relative(resolvedDir, resolvedMeta).split(path.sep).join('/');
756
+ try {
757
+ const git = simpleGit(dir);
758
+ const status = await git.status();
759
+ const dirty = status.files.some((f) => f.path === rel);
760
+ if (!dirty)
761
+ return null;
762
+ await git.add([rel]);
763
+ const machine = path.basename(path.dirname(resolvedMeta));
764
+ await git.commit(`chore(devices): snapshot ${machine} agent pins`, [rel]);
765
+ return rel;
766
+ }
767
+ catch {
768
+ return null; // best-effort — never let this block the pull path
769
+ }
770
+ }
736
771
  export async function pullRepo(dir) {
737
772
  try {
738
773
  const git = simpleGit(dir);
774
+ // Commit this machine's own device-meta first so a per-machine pin change
775
+ // never wedges the pull. Genuine edits elsewhere still block below.
776
+ await commitOwnDeviceMeta(dir);
739
777
  const status = await git.status();
740
778
  if (!status.isClean()) {
741
779
  return {
@@ -10,6 +10,7 @@
10
10
  * runs to be discoverable there first; until then the bounded tail is the safe
11
11
  * concise default.) Kept in one place so the two commands can never drift.
12
12
  */
13
+ import { type HostTask } from './tasks.js';
13
14
  export interface HostLogResult {
14
15
  /** False when no host task with this id exists (caller may fall through to sessions). */
15
16
  found: boolean;
@@ -21,5 +22,16 @@ export interface HostLogResult {
21
22
  * default; `full` dumps the entire raw combined-stdout log.
22
23
  */
23
24
  export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
25
+ /**
26
+ * Machine-readable form of a host-dispatch task's log — the task record plus its
27
+ * combined stdout. Powers `agents logs <id> --json` for the host-task branch.
28
+ * Reconciles a still-'running' record from the remote `.exit` first, like the
29
+ * text path does.
30
+ */
31
+ export declare function hostTaskLogJson(id: string): {
32
+ found: boolean;
33
+ task?: HostTask;
34
+ log?: string | null;
35
+ };
24
36
  /** Last `n` lines of `text`, prefixed with an elision note when truncated. */
25
37
  export declare function tailLines(text: string, n: number): string;