@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.
- package/CHANGELOG.md +9 -0
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/sessions-sync.d.ts +13 -0
- package/dist/commands/sessions-sync.js +73 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/sync.d.ts +10 -3
- package/dist/commands/sync.js +72 -9
- package/dist/commands/view.js +11 -3
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +11 -9
- package/dist/lib/daemon.d.ts +19 -0
- package/dist/lib/daemon.js +97 -2
- package/dist/lib/hooks.js +12 -0
- package/dist/lib/migrate.d.ts +22 -0
- package/dist/lib/migrate.js +99 -1
- package/dist/lib/plugin-marketplace.d.ts +15 -0
- package/dist/lib/plugin-marketplace.js +54 -0
- package/dist/lib/secrets/drivers/rush.d.ts +14 -0
- package/dist/lib/secrets/drivers/rush.js +84 -0
- package/dist/lib/secrets/index.js +20 -0
- package/dist/lib/secrets/linux.js +88 -10
- package/dist/lib/secrets/sync-backend.d.ts +48 -0
- package/dist/lib/secrets/sync-backend.js +13 -0
- package/dist/lib/secrets/sync.d.ts +15 -23
- package/dist/lib/secrets/sync.js +31 -66
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +168 -2
- package/dist/lib/session/sync/agents.d.ts +46 -0
- package/dist/lib/session/sync/agents.js +94 -0
- package/dist/lib/session/sync/config.d.ts +30 -0
- package/dist/lib/session/sync/config.js +58 -0
- package/dist/lib/session/sync/crdt.d.ts +44 -0
- package/dist/lib/session/sync/crdt.js +119 -0
- package/dist/lib/session/sync/manifest.d.ts +51 -0
- package/dist/lib/session/sync/manifest.js +96 -0
- package/dist/lib/session/sync/r2.d.ts +32 -0
- package/dist/lib/session/sync/r2.js +121 -0
- package/dist/lib/session/sync/sync.d.ts +82 -0
- package/dist/lib/session/sync/sync.js +251 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -1
- package/dist/lib/sync-umbrella.d.ts +76 -0
- package/dist/lib/sync-umbrella.js +125 -0
- package/dist/lib/teams/parsers.js +159 -1
- package/dist/lib/usage.d.ts +18 -0
- package/dist/lib/usage.js +25 -0
- package/dist/lib/versions.js +30 -13
- package/package.json +2 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -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, '&')
|
|
306
|
+
.replace(/</g, '<')
|
|
307
|
+
.replace(/>/g, '>');
|
|
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
|
}
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/migrate.js
CHANGED
|
@@ -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,
|