@phnx-labs/agents-cli 1.20.15 → 1.20.17

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 (49) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/commands/secrets.js +53 -1
  3. package/dist/commands/sessions-sync.d.ts +13 -0
  4. package/dist/commands/sessions-sync.js +73 -0
  5. package/dist/commands/sessions.js +2 -0
  6. package/dist/commands/sync.d.ts +10 -3
  7. package/dist/commands/sync.js +72 -9
  8. package/dist/commands/view.js +11 -3
  9. package/dist/index.js +1 -1
  10. package/dist/lib/agents.d.ts +11 -0
  11. package/dist/lib/agents.js +11 -9
  12. package/dist/lib/daemon.d.ts +19 -0
  13. package/dist/lib/daemon.js +97 -2
  14. package/dist/lib/hooks.js +12 -0
  15. package/dist/lib/migrate.d.ts +22 -0
  16. package/dist/lib/migrate.js +99 -1
  17. package/dist/lib/plugin-marketplace.d.ts +15 -0
  18. package/dist/lib/plugin-marketplace.js +54 -0
  19. package/dist/lib/secrets/drivers/rush.d.ts +14 -0
  20. package/dist/lib/secrets/drivers/rush.js +84 -0
  21. package/dist/lib/secrets/index.js +20 -0
  22. package/dist/lib/secrets/linux.js +88 -10
  23. package/dist/lib/secrets/sync-backend.d.ts +48 -0
  24. package/dist/lib/secrets/sync-backend.js +13 -0
  25. package/dist/lib/secrets/sync.d.ts +15 -23
  26. package/dist/lib/secrets/sync.js +31 -66
  27. package/dist/lib/session/parse.d.ts +2 -0
  28. package/dist/lib/session/parse.js +168 -2
  29. package/dist/lib/session/sync/agents.d.ts +46 -0
  30. package/dist/lib/session/sync/agents.js +94 -0
  31. package/dist/lib/session/sync/config.d.ts +30 -0
  32. package/dist/lib/session/sync/config.js +58 -0
  33. package/dist/lib/session/sync/crdt.d.ts +44 -0
  34. package/dist/lib/session/sync/crdt.js +119 -0
  35. package/dist/lib/session/sync/manifest.d.ts +51 -0
  36. package/dist/lib/session/sync/manifest.js +96 -0
  37. package/dist/lib/session/sync/r2.d.ts +32 -0
  38. package/dist/lib/session/sync/r2.js +121 -0
  39. package/dist/lib/session/sync/sync.d.ts +82 -0
  40. package/dist/lib/session/sync/sync.js +251 -0
  41. package/dist/lib/shims.d.ts +1 -1
  42. package/dist/lib/shims.js +17 -1
  43. package/dist/lib/sync-umbrella.d.ts +76 -0
  44. package/dist/lib/sync-umbrella.js +125 -0
  45. package/dist/lib/teams/parsers.js +159 -1
  46. package/dist/lib/usage.d.ts +18 -0
  47. package/dist/lib/usage.js +25 -0
  48. package/dist/lib/versions.js +30 -13
  49. package/package.json +2 -1
@@ -18,6 +18,7 @@ import { executeJobDetached, monitorRunningJobs } from './runner.js';
18
18
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
19
19
  import { BrowserService } from './browser/service.js';
20
20
  import { BrowserIPCServer } from './browser/ipc.js';
21
+ import { readAndResolveBundleEnv } from './secrets/bundles.js';
21
22
  const PID_FILE = 'daemon.pid';
22
23
  const LOCK_FILE = 'daemon.lock';
23
24
  const LOG_FILE = 'logs.jsonl';
@@ -25,6 +26,12 @@ const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
25
26
  const LOG_ROTATE_COUNT = 3;
26
27
  const PLIST_NAME = 'com.phnx-labs.agents-daemon';
27
28
  const SYSTEMD_UNIT = 'agents-daemon.service';
29
+ // A long-lived `claude setup-token` value stored in this secrets bundle/key is
30
+ // baked into the daemon's service-manager environment so headless routine runs
31
+ // authenticate without depending on the short-lived interactive Keychain OAuth
32
+ // session (which expires between runs and produces intermittent 401s).
33
+ const DAEMON_OAUTH_BUNDLE = 'claude';
34
+ const DAEMON_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
28
35
  function getDaemonDir() {
29
36
  const dir = getDaemonDirRoot();
30
37
  fs.mkdirSync(dir, { recursive: true });
@@ -225,6 +232,34 @@ export async function runDaemon() {
225
232
  const monitorInterval = setInterval(() => {
226
233
  monitorRunningJobs();
227
234
  }, 60_000);
235
+ // Cross-machine session sync: push this machine's transcripts to R2 and pull
236
+ // every other machine's, ~every 90s. Skipped silently when the r2.backups
237
+ // bundle is absent. An overlap guard prevents a slow cycle from stacking.
238
+ let syncing = false;
239
+ const runSessionSync = async () => {
240
+ if (syncing)
241
+ return;
242
+ syncing = true;
243
+ try {
244
+ const { isSyncConfigured } = await import('./session/sync/config.js');
245
+ if (!isSyncConfigured())
246
+ return;
247
+ const { syncSessions } = await import('./session/sync/sync.js');
248
+ const r = await syncSessions();
249
+ if (r.pushed || r.pulled || r.errors.length) {
250
+ log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
251
+ (r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
252
+ }
253
+ }
254
+ catch (err) {
255
+ log('ERROR', `sessions sync failed: ${err.message}`);
256
+ }
257
+ finally {
258
+ syncing = false;
259
+ }
260
+ };
261
+ const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
262
+ void runSessionSync(); // kick once at startup
228
263
  const handleReload = () => {
229
264
  log('INFO', 'Reloading jobs (SIGHUP)');
230
265
  scheduler.reloadAll();
@@ -236,6 +271,7 @@ export async function runDaemon() {
236
271
  scheduler.stopAll();
237
272
  await browserIPC.stop();
238
273
  clearInterval(monitorInterval);
274
+ clearInterval(syncInterval);
239
275
  removeDaemonPid();
240
276
  process.exit(0);
241
277
  };
@@ -244,10 +280,42 @@ export async function runDaemon() {
244
280
  process.on('SIGINT', () => handleShutdown());
245
281
  await new Promise(() => { });
246
282
  }
283
+ /**
284
+ * Read the long-lived Claude OAuth token (from `claude setup-token`) that the
285
+ * user stored under the `claude` secrets bundle. Resolves the bundle the same
286
+ * way `agents run --secrets` does, so the token is found whether it was stored
287
+ * keychain-backed or as a literal. Returns null when the bundle/key isn't
288
+ * configured, the Keychain read is cancelled, or the platform has no keychain —
289
+ * the daemon then behaves exactly as before (relying on the interactive OAuth
290
+ * session). Never throws: a misconfigured token must not block daemon startup.
291
+ */
292
+ export function readDaemonClaudeOAuthToken() {
293
+ try {
294
+ const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, { caller: 'daemon' });
295
+ const token = (env[DAEMON_OAUTH_KEY] ?? '').trim();
296
+ return token.length > 0 ? token : null;
297
+ }
298
+ catch {
299
+ return null;
300
+ }
301
+ }
302
+ /** Escape a string for safe inclusion in an XML <string> node. */
303
+ function xmlEscape(s) {
304
+ return s
305
+ .replace(/&/g, '&amp;')
306
+ .replace(/</g, '&lt;')
307
+ .replace(/>/g, '&gt;');
308
+ }
247
309
  /** Generate a macOS launchd plist for auto-starting the daemon. */
248
310
  export function generateLaunchdPlist() {
249
311
  const agentsBin = getAgentsBinPath();
250
312
  const logPath = getLogPath();
313
+ const oauthToken = readDaemonClaudeOAuthToken();
314
+ const oauthEntry = oauthToken
315
+ ? `
316
+ <key>${DAEMON_OAUTH_KEY}</key>
317
+ <string>${xmlEscape(oauthToken)}</string>`
318
+ : '';
251
319
  return `<?xml version="1.0" encoding="UTF-8"?>
252
320
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
253
321
  <plist version="1.0">
@@ -271,7 +339,7 @@ export function generateLaunchdPlist() {
271
339
  <key>EnvironmentVariables</key>
272
340
  <dict>
273
341
  <key>PATH</key>
274
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string>
342
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.bun/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin</string>${oauthEntry}
275
343
  </dict>
276
344
  </dict>
277
345
  </plist>`;
@@ -279,6 +347,10 @@ export function generateLaunchdPlist() {
279
347
  /** Generate a Linux systemd user unit for auto-starting the daemon. */
280
348
  export function generateSystemdUnit() {
281
349
  const agentsBin = getAgentsBinPath();
350
+ const oauthToken = readDaemonClaudeOAuthToken();
351
+ const oauthLine = oauthToken
352
+ ? `\nEnvironment=${DAEMON_OAUTH_KEY}=${oauthToken}`
353
+ : '';
282
354
  return `[Unit]
283
355
  Description=Agents Daemon - Scheduled Job Runner
284
356
  After=network.target
@@ -288,7 +360,7 @@ Type=simple
288
360
  ExecStart=${agentsBin} daemon _run
289
361
  Restart=always
290
362
  RestartSec=10
291
- Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin
363
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node/v24.0.0/bin${oauthLine}
292
364
 
293
365
  [Install]
294
366
  WantedBy=default.target`;
@@ -338,6 +410,9 @@ function startDaemonLocked() {
338
410
  fs.mkdirSync(plistDir, { recursive: true });
339
411
  }
340
412
  fs.writeFileSync(plistPath, generateLaunchdPlist(), 'utf-8');
413
+ // The plist may embed a long-lived OAuth token in EnvironmentVariables;
414
+ // keep it owner-only so it isn't world/group readable on disk.
415
+ fs.chmodSync(plistPath, 0o600);
341
416
  try {
342
417
  execFileSync('launchctl', ['unload', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
343
418
  }
@@ -365,6 +440,8 @@ function startDaemonLocked() {
365
440
  fs.mkdirSync(unitDir, { recursive: true });
366
441
  }
367
442
  fs.writeFileSync(unitPath, generateSystemdUnit(), 'utf-8');
443
+ // May embed a long-lived OAuth token in an Environment= line; owner-only.
444
+ fs.chmodSync(unitPath, 0o600);
368
445
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
369
446
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
370
447
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
@@ -377,6 +454,23 @@ function startDaemonLocked() {
377
454
  }
378
455
  return startDetached();
379
456
  }
457
+ /**
458
+ * Environment for the detached daemon fallback. The launchd/systemd paths
459
+ * deliver the long-lived OAuth token via the service manifest's environment;
460
+ * the detached path has no manifest, so inject it here. Read happens during an
461
+ * interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
462
+ * the daemon then passes it to every routine run it spawns. An already-set
463
+ * value (e.g. inherited from launchd) is left untouched.
464
+ */
465
+ export function buildDetachedDaemonEnv(baseEnv = process.env) {
466
+ const env = { ...baseEnv };
467
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
468
+ const token = readDaemonClaudeOAuthToken();
469
+ if (token)
470
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
471
+ }
472
+ return env;
473
+ }
380
474
  function startDetached() {
381
475
  const agentsBin = getAgentsBinPath();
382
476
  const logPath = getLogPath();
@@ -384,6 +478,7 @@ function startDetached() {
384
478
  const child = spawn(agentsBin, ['daemon', '_run'], {
385
479
  stdio: ['ignore', logFd, logFd],
386
480
  detached: true,
481
+ env: buildDetachedDaemonEnv(),
387
482
  });
388
483
  child.unref();
389
484
  fs.closeSync(logFd);
package/dist/lib/hooks.js CHANGED
@@ -90,6 +90,18 @@ function isManagedHookCommand(command, prefixes) {
90
90
  for (const prefix of prefixes) {
91
91
  if (resolved.startsWith(prefix))
92
92
  return true;
93
+ // The command dir above is realpath-resolved, but a raw prefix may still
94
+ // point through a symlink (macOS TMPDIR /var -> /private/var, or a
95
+ // symlinked ~/.agents). Compare against a realpath-normalized prefix too
96
+ // so the two sides match. Strip the trailing sep, resolve the dir, re-add.
97
+ const rawPrefixDir = prefix.endsWith(path.sep) ? prefix.slice(0, -path.sep.length) : prefix;
98
+ let resolvedPrefix = prefix;
99
+ try {
100
+ resolvedPrefix = fs.realpathSync(rawPrefixDir) + path.sep;
101
+ }
102
+ catch { /* absent or broken link */ }
103
+ if (resolvedPrefix !== prefix && resolved.startsWith(resolvedPrefix))
104
+ return true;
93
105
  }
94
106
  return false;
95
107
  }
@@ -25,6 +25,28 @@
25
25
  * LEGACY_SYSTEM_DIR" without duplicating data.
26
26
  */
27
27
  export declare function foldLegacySystemRepo(): void;
28
+ /**
29
+ * Repair self-referential agent binary symlinks.
30
+ *
31
+ * Some installScript-based agents — notably Factory's `droid`, whose installer
32
+ * drops a standalone native binary at ~/.local/bin/droid — were registered at
33
+ * install time by resolving the post-install binary with `which <cli>`. Because
34
+ * ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
35
+ * return OUR OWN dispatcher shim, and the install step symlinked
36
+ * ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
37
+ * back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
38
+ * dispatcher forever (an infinite exec loop that hangs the terminal).
39
+ *
40
+ * This walks every installed version's node_modules/.bin and, for any entry
41
+ * whose symlink resolves into the shims dir, re-points it at the real binary
42
+ * (found on PATH with the shims dir excluded) — or removes it when no real
43
+ * binary can be found, letting getBinaryPath's per-agent resolver take over.
44
+ * Idempotent: a correctly-pointed link is left untouched on re-run.
45
+ *
46
+ * Params default to the real on-disk locations; they are injectable so tests
47
+ * can drive a fixture tree without touching the user's ~/.agents.
48
+ */
49
+ export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string): void;
28
50
  /**
29
51
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
30
52
  * inside every installed agent version-home, and rewrite cross-references in
@@ -8,7 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as os from 'os';
10
10
  import * as yaml from 'yaml';
11
- import { AGENTS, agentConfigDirName } from './agents.js';
11
+ import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
12
12
  const HOME = process.env.HOME ?? os.homedir();
13
13
  const USER_DIR = path.join(HOME, '.agents');
14
14
  /** Canonical system-repo location (post-fold). */
@@ -715,6 +715,100 @@ function repairAgentConfigSymlinks() {
715
715
  console.error(`Repaired ${repaired} agent config symlink${repaired === 1 ? '' : 's'} to point at ~/.agents/versions/`);
716
716
  }
717
717
  }
718
+ /**
719
+ * Repair self-referential agent binary symlinks.
720
+ *
721
+ * Some installScript-based agents — notably Factory's `droid`, whose installer
722
+ * drops a standalone native binary at ~/.local/bin/droid — were registered at
723
+ * install time by resolving the post-install binary with `which <cli>`. Because
724
+ * ~/.agents/.cache/shims sits ahead of ~/.local/bin on PATH, `which` could
725
+ * return OUR OWN dispatcher shim, and the install step symlinked
726
+ * ~/.agents/.history/versions/<agent>/<version>/node_modules/.bin/<cli>
727
+ * back at ~/.agents/.cache/shims/<cli>. Launching that agent then re-execs the
728
+ * dispatcher forever (an infinite exec loop that hangs the terminal).
729
+ *
730
+ * This walks every installed version's node_modules/.bin and, for any entry
731
+ * whose symlink resolves into the shims dir, re-points it at the real binary
732
+ * (found on PATH with the shims dir excluded) — or removes it when no real
733
+ * binary can be found, letting getBinaryPath's per-agent resolver take over.
734
+ * Idempotent: a correctly-pointed link is left untouched on re-run.
735
+ *
736
+ * Params default to the real on-disk locations; they are injectable so tests
737
+ * can drive a fixture tree without touching the user's ~/.agents.
738
+ */
739
+ export function repairSelfReferentialBinShims(versionsRoot = path.join(HISTORY_DIR, 'versions'), shimsDir = path.resolve(CACHE_DIR, 'shims')) {
740
+ // Normalize the shims dir through realpath so the prefix check below survives
741
+ // a symlinked ~/.agents (or macOS's /tmp -> /private/tmp): fs.realpathSync on
742
+ // the link target resolves those symlinks, so the dir we compare against must
743
+ // too, or every loop would read as "points at a real binary" and be skipped.
744
+ shimsDir = path.resolve(shimsDir);
745
+ try {
746
+ shimsDir = fs.realpathSync(shimsDir);
747
+ }
748
+ catch {
749
+ /* shims dir absent — leave the resolved path; nothing will match it */
750
+ }
751
+ let agents;
752
+ try {
753
+ agents = fs.readdirSync(versionsRoot);
754
+ }
755
+ catch {
756
+ return; // no versions installed yet
757
+ }
758
+ let repaired = 0;
759
+ for (const agent of agents) {
760
+ const cli = agent in AGENTS ? AGENTS[agent].cliCommand : agent;
761
+ let versions;
762
+ try {
763
+ versions = fs.readdirSync(path.join(versionsRoot, agent));
764
+ }
765
+ catch {
766
+ continue;
767
+ }
768
+ for (const version of versions) {
769
+ const binLink = path.join(versionsRoot, agent, version, 'node_modules', '.bin', cli);
770
+ let stat;
771
+ try {
772
+ stat = fs.lstatSync(binLink);
773
+ }
774
+ catch {
775
+ continue; // no .bin entry for this version
776
+ }
777
+ if (!stat.isSymbolicLink())
778
+ continue;
779
+ let real;
780
+ try {
781
+ real = fs.realpathSync(binLink);
782
+ }
783
+ catch {
784
+ // Dangling symlink — if it was aimed at the shims dir it's the loop
785
+ // residue; drop it either way so getBinaryPath reports honestly.
786
+ try {
787
+ fs.unlinkSync(binLink);
788
+ repaired++;
789
+ }
790
+ catch { /* best-effort */ }
791
+ continue;
792
+ }
793
+ if (!path.resolve(real).startsWith(shimsDir + path.sep))
794
+ continue; // points at a real binary — fine
795
+ // Self-referential: the link resolves back into our own shims dir.
796
+ // findInPath does a pure-Node PATH scan (no subprocess) and already
797
+ // skips our shims dir, so it returns the genuine install if one exists.
798
+ const realBinary = findInPath(cli);
799
+ try {
800
+ fs.unlinkSync(binLink);
801
+ if (realBinary)
802
+ fs.symlinkSync(realBinary, binLink);
803
+ repaired++;
804
+ }
805
+ catch { /* best-effort */ }
806
+ }
807
+ }
808
+ if (repaired > 0) {
809
+ console.error(`Repaired ${repaired} self-referential agent binary symlink${repaired === 1 ? '' : 's'} (infinite exec-loop fix).`);
810
+ }
811
+ }
718
812
  /**
719
813
  * Move a directory from `src` to `dest`. No-op when src is absent. When dest
720
814
  * already exists, merge by copying everything that isn't already there, then
@@ -1733,4 +1827,8 @@ export async function runMigration() {
1733
1827
  migrateExtrasExtrasToAgentsExtras();
1734
1828
  // Symlink repair runs LAST so it can find the post-move version homes.
1735
1829
  repairAgentConfigSymlinks();
1830
+ // Repair self-referential node_modules/.bin/<cli> symlinks (the droid
1831
+ // infinite-exec-loop). Also runs after the bucket moves so it scans the
1832
+ // canonical HISTORY_DIR/versions tree.
1833
+ repairSelfReferentialBinShims();
1736
1834
  }
@@ -98,6 +98,21 @@ export declare function knownMarketplacesPath(agent: AgentId, versionHome: strin
98
98
  * Internal symlinks (target stays inside the plugin root) are preserved.
99
99
  */
100
100
  export declare function copyPluginToMarketplace(plugin: DiscoveredPlugin, spec: MarketplaceSpec | string, agent: AgentId, versionHome: string): string;
101
+ /**
102
+ * Claude Code's plugin-manifest schema requires the resource path fields to be
103
+ * relative paths starting with "./" — `skills`/`commands`/`agents` are
104
+ * `union([startsWith("./"), array(startsWith("./"))])` (verified against the
105
+ * Claude Code binary). Bare names like "loop" fail validation and Claude rejects
106
+ * the ENTIRE plugin at load time, surfacing only in its `/plugin` > Errors tab.
107
+ *
108
+ * agents-cli copies plugin.json verbatim into the marketplace, so a malformed
109
+ * manifest ships looking fully installed while loading nothing. This catches the
110
+ * unambiguous type violation (non-"./" string entries) and returns one warning
111
+ * per offending field so the caller can surface it loudly. `hooks`/`mcpServers`
112
+ * are intentionally skipped — they legitimately accept inline objects, so a path
113
+ * check would false-positive.
114
+ */
115
+ export declare function validateClaudePluginManifest(manifest: unknown): string[];
101
116
  /**
102
117
  * Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
103
118
  * already installed under <marketplace>/plugins/. Always run after add or remove
@@ -185,6 +185,54 @@ export function copyPluginToMarketplace(plugin, spec, agent, versionHome) {
185
185
  }
186
186
  return dest;
187
187
  }
188
+ // ─── Manifest validation ─────────────────────────────────────────────────────
189
+ /**
190
+ * Claude Code's plugin-manifest schema requires the resource path fields to be
191
+ * relative paths starting with "./" — `skills`/`commands`/`agents` are
192
+ * `union([startsWith("./"), array(startsWith("./"))])` (verified against the
193
+ * Claude Code binary). Bare names like "loop" fail validation and Claude rejects
194
+ * the ENTIRE plugin at load time, surfacing only in its `/plugin` > Errors tab.
195
+ *
196
+ * agents-cli copies plugin.json verbatim into the marketplace, so a malformed
197
+ * manifest ships looking fully installed while loading nothing. This catches the
198
+ * unambiguous type violation (non-"./" string entries) and returns one warning
199
+ * per offending field so the caller can surface it loudly. `hooks`/`mcpServers`
200
+ * are intentionally skipped — they legitimately accept inline objects, so a path
201
+ * check would false-positive.
202
+ */
203
+ export function validateClaudePluginManifest(manifest) {
204
+ const warnings = [];
205
+ if (!manifest || typeof manifest !== 'object')
206
+ return warnings;
207
+ const m = manifest;
208
+ for (const field of ['skills', 'commands', 'agents']) {
209
+ const value = m[field];
210
+ if (value === undefined || value === null)
211
+ continue;
212
+ // How-to-fix written so a human OR a coding agent reading stderr can act
213
+ // without further investigation. Deleting the field is the recommended fix:
214
+ // Claude auto-discovers skills/commands/agents from their directories, which
215
+ // is why every well-formed plugin omits these fields entirely.
216
+ const fix = `Fix: delete the "${field}" field from plugin.json (recommended — Claude ` +
217
+ `auto-discovers from the ${field}/ directory), or rewrite every entry as a ` +
218
+ `"./"-relative path (e.g. "./${field}/<name>").`;
219
+ const entries = Array.isArray(value) ? value : [value];
220
+ for (const entry of entries) {
221
+ if (typeof entry !== 'string') {
222
+ warnings.push(`field "${field}" must be a "./"-relative path string or an array of them; ` +
223
+ `found a non-string entry. Claude Code silently rejects the ENTIRE plugin. ${fix}`);
224
+ break;
225
+ }
226
+ if (!entry.startsWith('./')) {
227
+ warnings.push(`field "${field}" entry "${entry}" must be a relative path starting with "./" ` +
228
+ `(e.g. "./${field}/${entry}"), not a bare name. Claude Code silently rejects the ` +
229
+ `ENTIRE plugin — no commands or skills load. ${fix}`);
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ return warnings;
235
+ }
188
236
  // ─── Catalog synthesis ──────────────────────────────────────────────────────
189
237
  /**
190
238
  * Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
@@ -226,6 +274,12 @@ export function syncMarketplaceManifest(spec, agent, versionHome) {
226
274
  catch {
227
275
  continue;
228
276
  }
277
+ for (const warning of validateClaudePluginManifest(manifest)) {
278
+ // Reference the plugin by name, not the marketplace-copy path: that copy is
279
+ // regenerated from source on every sync, so editing it gets stomped. The fix
280
+ // belongs in the plugin's SOURCE .claude-plugin/plugin.json.
281
+ process.stderr.write(`agents-cli: plugin '${manifest.name ?? entry.name}' has a Claude-invalid manifest — ${warning}\n`);
282
+ }
229
283
  entries.push({
230
284
  name: manifest.name,
231
285
  source: `./plugins/${manifest.name}`,
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Rush `SyncBackend` driver — the original (and currently default) transport
3
+ * for `agents secrets push/pull`. Talks to api.prix.dev and authenticates with
4
+ * the session token written by `rush login` (`~/.rush/user.yaml`).
5
+ *
6
+ * This is the ONE place in the secrets module allowed to reference Rush
7
+ * (api.prix.dev / ~/.rush). It is an opt-in driver kept for backwards
8
+ * compatibility with bundles already pushed to Rush; `sync.ts` selects it as
9
+ * the default but the transport seam (`SyncBackend`) lets other backends drop
10
+ * in without touching the crypto or push/pull logic.
11
+ */
12
+ import type { SyncBackend } from '../sync-backend.js';
13
+ /** The Rush transport. Plaintext never reaches here — only ciphertext envelopes. */
14
+ export declare const rushSyncBackend: SyncBackend;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Rush `SyncBackend` driver — the original (and currently default) transport
3
+ * for `agents secrets push/pull`. Talks to api.prix.dev and authenticates with
4
+ * the session token written by `rush login` (`~/.rush/user.yaml`).
5
+ *
6
+ * This is the ONE place in the secrets module allowed to reference Rush
7
+ * (api.prix.dev / ~/.rush). It is an opt-in driver kept for backwards
8
+ * compatibility with bundles already pushed to Rush; `sync.ts` selects it as
9
+ * the default but the transport seam (`SyncBackend`) lets other backends drop
10
+ * in without touching the crypto or push/pull logic.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ import * as yaml from 'yaml';
16
+ const PROXY_BASE = 'https://api.prix.dev';
17
+ const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
18
+ const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
19
+ function readRushToken() {
20
+ if (!fs.existsSync(USER_YAML)) {
21
+ throw new Error('Not logged in to Rush. Run `rush login` first.');
22
+ }
23
+ const raw = fs.readFileSync(USER_YAML, 'utf-8');
24
+ const data = yaml.parse(raw);
25
+ const token = data?.session?.access_token;
26
+ if (!token) {
27
+ throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
28
+ }
29
+ return token;
30
+ }
31
+ async function api(method, endpoint, body) {
32
+ const token = readRushToken();
33
+ const url = endpoint.startsWith('http') ? endpoint : `${PROXY_BASE}${endpoint}`;
34
+ return fetch(url, {
35
+ method,
36
+ headers: {
37
+ Authorization: `Bearer ${token}`,
38
+ 'Content-Type': 'application/json',
39
+ },
40
+ body: body === undefined ? undefined : JSON.stringify(body),
41
+ });
42
+ }
43
+ function bundlePath(name) {
44
+ return `${BUNDLE_ENDPOINT}/${encodeURIComponent(name)}`;
45
+ }
46
+ /** The Rush transport. Plaintext never reaches here — only ciphertext envelopes. */
47
+ export const rushSyncBackend = {
48
+ async putEnvelope(name, payload) {
49
+ const res = await api('PUT', bundlePath(name), payload);
50
+ if (!res.ok) {
51
+ const body = await res.text().catch(() => '');
52
+ throw new Error(`Push failed (${res.status} ${res.statusText}): ${body}`);
53
+ }
54
+ },
55
+ async getEnvelope(name) {
56
+ const res = await api('GET', bundlePath(name));
57
+ if (res.status === 404)
58
+ return null;
59
+ if (!res.ok) {
60
+ const body = await res.text().catch(() => '');
61
+ throw new Error(`Pull failed (${res.status} ${res.statusText}): ${body}`);
62
+ }
63
+ return await res.json();
64
+ },
65
+ async deleteEnvelope(name) {
66
+ const res = await api('DELETE', bundlePath(name));
67
+ if (res.status === 404)
68
+ return false;
69
+ if (!res.ok) {
70
+ const body = await res.text().catch(() => '');
71
+ throw new Error(`Delete failed (${res.status} ${res.statusText}): ${body}`);
72
+ }
73
+ return true;
74
+ },
75
+ async listEnvelopes() {
76
+ const res = await api('GET', BUNDLE_ENDPOINT);
77
+ if (!res.ok) {
78
+ const body = await res.text().catch(() => '');
79
+ throw new Error(`List failed (${res.status} ${res.statusText}): ${body}`);
80
+ }
81
+ const data = await res.json();
82
+ return data.bundles ?? [];
83
+ },
84
+ };
@@ -236,6 +236,26 @@ export function setKeychainToken(item, value) {
236
236
  linuxBackend.set(item, value);
237
237
  return;
238
238
  }
239
+ // Bare (non-`agents-cli.`) items are written WITHOUT the biometry ACL so
240
+ // they round-trip with the no-prompt read path in getKeychainToken (which
241
+ // also uses /usr/bin/security for non-our items). This is what lets a
242
+ // SessionStart hook read e.g. `linear-api-key` silently on every launch.
243
+ // Routing these through the helper would attach a Touch ID ACL that the
244
+ // /usr/bin/security read can't satisfy without popping the legacy password
245
+ // sheet. -U upserts so repeated sets overwrite in place.
246
+ if (!isOurItem(item)) {
247
+ const sec = spawnSync('/usr/bin/security', [
248
+ 'add-generic-password', '-U',
249
+ '-a', os.userInfo().username,
250
+ '-s', item,
251
+ '-w', value,
252
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
253
+ if (sec.status !== 0) {
254
+ const msg = sec.stderr?.toString().trim();
255
+ throw new Error(msg || `Failed to write keychain item '${item}'.`);
256
+ }
257
+ return;
258
+ }
239
259
  const bin = getKeychainHelperPath();
240
260
  const result = spawnSync(bin, ['set', item, os.userInfo().username], {
241
261
  input: value,