@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.
- package/CHANGELOG.md +85 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/commands.js +2 -0
- package/dist/commands/exec.js +12 -0
- package/dist/commands/fleet-capture.d.ts +14 -0
- package/dist/commands/fleet-capture.js +107 -0
- package/dist/commands/fork.js +13 -7
- package/dist/commands/hosts.js +11 -0
- package/dist/commands/logs.js +38 -9
- package/dist/commands/mcp.js +2 -0
- package/dist/commands/resource-view.d.ts +2 -0
- package/dist/commands/resource-view.js +8 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +24 -4
- package/dist/commands/sessions-tail.js +1 -2
- package/dist/commands/sessions.d.ts +6 -0
- package/dist/commands/sessions.js +14 -6
- package/dist/commands/skills.js +2 -0
- package/dist/commands/ssh.js +24 -6
- package/dist/commands/status.js +8 -2
- package/dist/commands/subagents.js +3 -1
- package/dist/commands/uninstall.d.ts +11 -0
- package/dist/commands/uninstall.js +158 -0
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/utils.d.ts +29 -0
- package/dist/commands/utils.js +24 -0
- package/dist/commands/versions.js +120 -11
- package/dist/index.js +5 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/daemon.d.ts +9 -0
- package/dist/lib/daemon.js +43 -0
- package/dist/lib/devices/connect.d.ts +13 -0
- package/dist/lib/devices/connect.js +20 -0
- package/dist/lib/devices/fleet.d.ts +36 -3
- package/dist/lib/devices/fleet.js +42 -4
- package/dist/lib/devices/sync.d.ts +30 -0
- package/dist/lib/devices/sync.js +50 -0
- package/dist/lib/doctor-diff.js +38 -16
- package/dist/lib/fleet/apply.d.ts +4 -0
- package/dist/lib/fleet/apply.js +28 -5
- package/dist/lib/fleet/capture.d.ts +35 -0
- package/dist/lib/fleet/capture.js +51 -0
- package/dist/lib/fleet/manifest.d.ts +6 -0
- package/dist/lib/fleet/manifest.js +24 -1
- package/dist/lib/fleet/types.d.ts +24 -1
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +39 -1
- package/dist/lib/hosts/logs.d.ts +12 -0
- package/dist/lib/hosts/logs.js +18 -0
- package/dist/lib/menubar/install-menubar.d.ts +12 -0
- package/dist/lib/menubar/install-menubar.js +26 -13
- package/dist/lib/secrets/agent.d.ts +5 -0
- package/dist/lib/secrets/agent.js +35 -3
- package/dist/lib/secrets/bundles.js +28 -0
- package/dist/lib/secrets/index.d.ts +14 -0
- package/dist/lib/secrets/index.js +36 -5
- package/dist/lib/secrets/session-store.d.ts +90 -0
- package/dist/lib/secrets/session-store.js +221 -0
- package/dist/lib/session/render.d.ts +9 -1
- package/dist/lib/session/render.js +35 -4
- package/dist/lib/share/capture.d.ts +2 -0
- package/dist/lib/share/capture.js +12 -5
- package/dist/lib/shims.d.ts +27 -0
- package/dist/lib/shims.js +29 -3
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +5 -1
- package/dist/lib/uninstall.d.ts +84 -0
- package/dist/lib/uninstall.js +347 -0
- package/dist/lib/usage.d.ts +13 -1
- package/dist/lib/usage.js +32 -14
- package/dist/lib/versions.d.ts +16 -0
- package/dist/lib/versions.js +40 -3
- package/package.json +1 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Complete teardown of agents-cli — the reverse of `agents setup`.
|
|
3
|
+
*
|
|
4
|
+
* The hard part is undoing "adoption": normal installs move the user's real
|
|
5
|
+
* `~/.<agent>` aside and replace it with a symlink into agents-cli's version
|
|
6
|
+
* homes (see `switchConfigSymlink` / `importAgent`). A clean uninstall must put
|
|
7
|
+
* those real directories back, release any adopted launchers, strip the shim
|
|
8
|
+
* directory from the user's PATH, and only then dispose of `~/.agents`.
|
|
9
|
+
*
|
|
10
|
+
* Safety invariant: a `~/.<agent>` that agents-cli never adopted is a REAL user
|
|
11
|
+
* directory and is never touched. Ownership is decided structurally by
|
|
12
|
+
* `getConfigSymlinkVersion` (non-null only for a symlink into our versions dir),
|
|
13
|
+
* exactly as `removeVersion` does it — no marker files, no guessing.
|
|
14
|
+
*
|
|
15
|
+
* This module is split into a read-only {@link planUninstall} and a mutating
|
|
16
|
+
* {@link executeUninstall} so `--dry-run` and the real run share one code path.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as os from 'os';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import { AGENTS, ALL_AGENT_IDS } from './agents.js';
|
|
22
|
+
import { getAgentConfigPath, getConfigSymlinkVersion, stripShimPathLines, releaseAdoptedLauncher, } from './shims.js';
|
|
23
|
+
import { getUserAgentsDir, getBackupsDir, getHistoryDir, getShimsDir, getLegacySystemAgentsDir, } from './state.js';
|
|
24
|
+
/** Home dir honoring the AGENTS_REAL_HOME test/override, mirroring shims.ts. */
|
|
25
|
+
function realHome() {
|
|
26
|
+
return process.env.AGENTS_REAL_HOME || os.homedir();
|
|
27
|
+
}
|
|
28
|
+
/** Newest timestamped backup dir under `<backups>/<agent>/`, or null. */
|
|
29
|
+
function newestBackupDir(agent) {
|
|
30
|
+
const dir = path.join(getBackupsDir(), agent);
|
|
31
|
+
let entries;
|
|
32
|
+
try {
|
|
33
|
+
entries = fs.readdirSync(dir).filter((n) => /^\d+$/.test(n));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (entries.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
entries.sort((a, b) => Number(a) - Number(b));
|
|
41
|
+
return path.join(dir, entries[entries.length - 1]);
|
|
42
|
+
}
|
|
43
|
+
/** Absolute target of a symlink, or null if `p` is not a readable symlink. */
|
|
44
|
+
function symlinkTarget(p) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = fs.readlinkSync(p);
|
|
47
|
+
return path.resolve(path.dirname(p), raw);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Remove a symlink/junction at `p` without following into its target. On POSIX
|
|
55
|
+
* this is a plain `unlinkSync`; on Windows the same call correctly deletes a
|
|
56
|
+
* junction or directory-symlink reparse point while leaving the target intact —
|
|
57
|
+
* verified on a real Windows host, where `fs.rmSync(p, { force: true })` instead
|
|
58
|
+
* throws `EFAULT` on a reparse point. `rmSync` is deliberately NOT used here.
|
|
59
|
+
*/
|
|
60
|
+
function removeLink(p) {
|
|
61
|
+
fs.unlinkSync(p);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Move `source` onto `dest` across possibly-different volumes. `renameSync` is
|
|
65
|
+
* atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
|
|
66
|
+
* `$HOME`; fall back to copy-then-remove so the restore still completes. The
|
|
67
|
+
* source (a backup inside `~/.agents`) is removed only after the copy succeeds,
|
|
68
|
+
* so a mid-copy failure never destroys the sole surviving copy.
|
|
69
|
+
*/
|
|
70
|
+
function moveDirCrossDevice(source, dest) {
|
|
71
|
+
try {
|
|
72
|
+
fs.renameSync(source, dest);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err.code !== 'EXDEV')
|
|
76
|
+
throw err;
|
|
77
|
+
fs.cpSync(source, dest, { recursive: true });
|
|
78
|
+
fs.rmSync(source, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Copy `source` to `dest`, dropping any symlink whose target resolves back into
|
|
83
|
+
* `~/.agents`. Adoption syncs managed resources (skills/commands) into the
|
|
84
|
+
* version home as symlinks into `~/.agents`; copying them verbatim would leave
|
|
85
|
+
* the restored config full of links that dangle the moment `~/.agents` is
|
|
86
|
+
* disposed. Stripping them yields a clean, self-contained restore.
|
|
87
|
+
*/
|
|
88
|
+
function copyDirStrippingAgentsSymlinks(source, dest, agentsDir) {
|
|
89
|
+
const inside = agentsDir + path.sep;
|
|
90
|
+
fs.cpSync(source, dest, {
|
|
91
|
+
recursive: true,
|
|
92
|
+
filter: (src) => {
|
|
93
|
+
try {
|
|
94
|
+
const st = fs.lstatSync(src);
|
|
95
|
+
if (st.isSymbolicLink()) {
|
|
96
|
+
const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
|
|
97
|
+
if (tgt === agentsDir || tgt.startsWith(inside))
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* unreadable entry — let cpSync surface it on the real copy */
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Classify one agent's config dir without mutating anything. */
|
|
109
|
+
function planConfig(agent) {
|
|
110
|
+
const realPath = getAgentConfigPath(agent);
|
|
111
|
+
let stat;
|
|
112
|
+
try {
|
|
113
|
+
stat = fs.lstatSync(realPath);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { agent, realPath, kind: 'absent' };
|
|
117
|
+
}
|
|
118
|
+
// A real directory means agents-cli never adopted it — leave it alone.
|
|
119
|
+
if (!stat.isSymbolicLink())
|
|
120
|
+
return { agent, realPath, kind: 'leave-real' };
|
|
121
|
+
// A symlink we don't own (target not under our versions dir) — leave it alone.
|
|
122
|
+
if (getConfigSymlinkVersion(agent) === null)
|
|
123
|
+
return { agent, realPath, kind: 'leave-foreign' };
|
|
124
|
+
// Owned symlink: prefer the timestamped backup (switchConfigSymlink moved the
|
|
125
|
+
// real dir there); otherwise the symlink target itself holds the user's data
|
|
126
|
+
// (importAgent renamed the real dir INTO the version home).
|
|
127
|
+
const backup = newestBackupDir(agent);
|
|
128
|
+
if (backup)
|
|
129
|
+
return { agent, realPath, kind: 'restore-backup', source: backup };
|
|
130
|
+
const target = symlinkTarget(realPath);
|
|
131
|
+
if (target && fs.existsSync(target)) {
|
|
132
|
+
return { agent, realPath, kind: 'restore-version-home', source: target };
|
|
133
|
+
}
|
|
134
|
+
return { agent, realPath, kind: 'remove-dangling' };
|
|
135
|
+
}
|
|
136
|
+
/** Owned home-file symlinks (e.g. `~/.claude.json`) to copy back as real files. */
|
|
137
|
+
function planHomeFiles() {
|
|
138
|
+
const home = realHome();
|
|
139
|
+
const userDir = getUserAgentsDir();
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const agent of ALL_AGENT_IDS) {
|
|
142
|
+
const homeFiles = AGENTS[agent].homeFiles;
|
|
143
|
+
if (!homeFiles)
|
|
144
|
+
continue;
|
|
145
|
+
for (const fileName of homeFiles) {
|
|
146
|
+
const realPath = path.join(home, fileName);
|
|
147
|
+
let stat;
|
|
148
|
+
try {
|
|
149
|
+
stat = fs.lstatSync(realPath);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!stat.isSymbolicLink())
|
|
155
|
+
continue;
|
|
156
|
+
const target = symlinkTarget(realPath);
|
|
157
|
+
// Only ours (points into ~/.agents) and only if the target still exists.
|
|
158
|
+
if (target && target.startsWith(userDir + path.sep) && fs.existsSync(target)) {
|
|
159
|
+
out.push({ realPath, source: target });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/** cliCommand basenames that have an adopted-launcher record to release. */
|
|
166
|
+
function planLaunchers() {
|
|
167
|
+
const dir = path.join(getHistoryDir(), 'adopted-launchers');
|
|
168
|
+
try {
|
|
169
|
+
return fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Candidate shell rc files that currently contain a shim PATH entry. */
|
|
176
|
+
function planRcFiles() {
|
|
177
|
+
const home = realHome();
|
|
178
|
+
const shimsDir = getShimsDir();
|
|
179
|
+
const candidates = ['.zshrc', '.bashrc', '.bash_profile', '.profile', path.join('.config', 'fish', 'config.fish')];
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const rel of candidates) {
|
|
182
|
+
const rc = path.join(home, rel);
|
|
183
|
+
let content;
|
|
184
|
+
try {
|
|
185
|
+
content = fs.readFileSync(rc, 'utf-8');
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (stripShimPathLines(content, shimsDir) !== content)
|
|
191
|
+
out.push(rc);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Build a read-only plan of everything a complete uninstall would change.
|
|
197
|
+
* Performs no mutations; safe to run for `--dry-run` and to print for confirm.
|
|
198
|
+
*/
|
|
199
|
+
export function planUninstall() {
|
|
200
|
+
const agentsDir = getUserAgentsDir();
|
|
201
|
+
const legacy = getLegacySystemAgentsDir();
|
|
202
|
+
let legacySymlink = null;
|
|
203
|
+
try {
|
|
204
|
+
// Only claim it if it's actually a link (symlink on POSIX, junction on Windows —
|
|
205
|
+
// both report isSymbolicLink()); a real directory here is left alone so removeLink
|
|
206
|
+
// (unlinkSync) is always the correct primitive for what we captured.
|
|
207
|
+
if (fs.lstatSync(legacy).isSymbolicLink())
|
|
208
|
+
legacySymlink = legacy;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
legacySymlink = null;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
isInstalled: fs.existsSync(agentsDir),
|
|
215
|
+
agentsDir,
|
|
216
|
+
legacySymlink,
|
|
217
|
+
configs: ALL_AGENT_IDS.map(planConfig),
|
|
218
|
+
homeFiles: planHomeFiles(),
|
|
219
|
+
launchers: planLaunchers(),
|
|
220
|
+
rcFiles: planRcFiles(),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Execute a plan built by {@link planUninstall}. Restores adopted config dirs
|
|
225
|
+
* and home files, releases adopted launchers, strips shim PATH lines, then
|
|
226
|
+
* disposes of `~/.agents` — moved aside to `~/.agents.removed-<ts>` (recoverable)
|
|
227
|
+
* by default, or hard-deleted when `purge` is set. Config restore always runs
|
|
228
|
+
* before disposal because the backups live inside `~/.agents`.
|
|
229
|
+
*/
|
|
230
|
+
export function executeUninstall(plan, opts) {
|
|
231
|
+
const result = {
|
|
232
|
+
restoredConfigs: [],
|
|
233
|
+
removedDanglingConfigs: [],
|
|
234
|
+
restoredHomeFiles: [],
|
|
235
|
+
releasedLaunchers: [],
|
|
236
|
+
cleanedRcFiles: [],
|
|
237
|
+
agentsDir: { path: plan.agentsDir, disposition: 'absent' },
|
|
238
|
+
legacySymlinkRemoved: false,
|
|
239
|
+
purgeDowngraded: false,
|
|
240
|
+
errors: [],
|
|
241
|
+
};
|
|
242
|
+
// 1. Restore adopted config directories (reads backups inside ~/.agents).
|
|
243
|
+
for (const c of plan.configs) {
|
|
244
|
+
try {
|
|
245
|
+
if (c.kind === 'restore-backup') {
|
|
246
|
+
// The adopted link carries no data (the real dir is the backup); drop it,
|
|
247
|
+
// then move the backup out of ~/.agents onto the real path — EXDEV-safe so a
|
|
248
|
+
// cross-volume ~/.agents can't strand the backup mid-restore. unlinkSync (not
|
|
249
|
+
// rmSync) is deliberate: it removes a POSIX symlink AND a Windows junction/
|
|
250
|
+
// dir-symlink without following into the target, whereas rmSync throws EFAULT
|
|
251
|
+
// on a Windows reparse point.
|
|
252
|
+
removeLink(c.realPath);
|
|
253
|
+
moveDirCrossDevice(c.source, c.realPath);
|
|
254
|
+
result.restoredConfigs.push({ agent: c.agent, realPath: c.realPath });
|
|
255
|
+
}
|
|
256
|
+
else if (c.kind === 'restore-version-home') {
|
|
257
|
+
// importAgent renamed the real dir INTO the version home; copy it back
|
|
258
|
+
// (step 6 disposes the original) while stripping resource symlinks that
|
|
259
|
+
// would dangle once ~/.agents is gone.
|
|
260
|
+
removeLink(c.realPath);
|
|
261
|
+
copyDirStrippingAgentsSymlinks(c.source, c.realPath, plan.agentsDir);
|
|
262
|
+
result.restoredConfigs.push({ agent: c.agent, realPath: c.realPath });
|
|
263
|
+
}
|
|
264
|
+
else if (c.kind === 'remove-dangling') {
|
|
265
|
+
removeLink(c.realPath);
|
|
266
|
+
result.removedDanglingConfigs.push({ agent: c.agent, realPath: c.realPath });
|
|
267
|
+
}
|
|
268
|
+
// leave-real / leave-foreign / absent: intentionally untouched.
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
result.errors.push(`config ${c.agent} (${c.realPath}): ${err.message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// 2. Restore owned home-file symlinks as real files (e.g. ~/.claude.json).
|
|
275
|
+
for (const hf of plan.homeFiles) {
|
|
276
|
+
try {
|
|
277
|
+
removeLink(hf.realPath);
|
|
278
|
+
fs.cpSync(hf.source, hf.realPath, { recursive: true });
|
|
279
|
+
result.restoredHomeFiles.push(hf.realPath);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
result.errors.push(`home file ${hf.realPath}: ${err.message}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// 3. Release adopted launchers (reads records inside ~/.agents).
|
|
286
|
+
const byCli = new Map(ALL_AGENT_IDS.map((a) => [AGENTS[a].cliCommand, a]));
|
|
287
|
+
for (const cli of plan.launchers) {
|
|
288
|
+
const agent = byCli.get(cli);
|
|
289
|
+
if (!agent)
|
|
290
|
+
continue;
|
|
291
|
+
try {
|
|
292
|
+
releaseAdoptedLauncher(agent);
|
|
293
|
+
result.releasedLaunchers.push(cli);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
result.errors.push(`launcher ${cli}: ${err.message}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// 4. Strip the shim directory from the user's PATH across all rc files.
|
|
300
|
+
const shimsDir = getShimsDir();
|
|
301
|
+
for (const rc of plan.rcFiles) {
|
|
302
|
+
try {
|
|
303
|
+
const content = fs.readFileSync(rc, 'utf-8');
|
|
304
|
+
fs.writeFileSync(rc, stripShimPathLines(content, shimsDir));
|
|
305
|
+
result.cleanedRcFiles.push(rc);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
result.errors.push(`rc file ${rc}: ${err.message}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// 5. Remove the legacy back-compat symlink, if present. `~/.agents-system` is a
|
|
312
|
+
// link (junction on Windows — createLink uses 'junction' for a dir source), so it
|
|
313
|
+
// goes through removeLink for the same reason as the config links: rmSync throws
|
|
314
|
+
// EFAULT on a Windows reparse point.
|
|
315
|
+
if (plan.legacySymlink) {
|
|
316
|
+
try {
|
|
317
|
+
removeLink(plan.legacySymlink);
|
|
318
|
+
result.legacySymlinkRemoved = true;
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
result.errors.push(`legacy ${plan.legacySymlink}: ${err.message}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// 6. Dispose of ~/.agents LAST (its backups fed step 1). If any restore above
|
|
325
|
+
// failed, downgrade a --purge to a recoverable move-aside: a swallowed restore
|
|
326
|
+
// error must never let the hard-delete take the user's only copy with it.
|
|
327
|
+
if (fs.existsSync(plan.agentsDir)) {
|
|
328
|
+
const purge = !!opts.purge && result.errors.length === 0;
|
|
329
|
+
if (opts.purge && !purge)
|
|
330
|
+
result.purgeDowngraded = true;
|
|
331
|
+
try {
|
|
332
|
+
if (purge) {
|
|
333
|
+
fs.rmSync(plan.agentsDir, { recursive: true, force: true });
|
|
334
|
+
result.agentsDir = { path: plan.agentsDir, disposition: 'purged' };
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const movedTo = `${plan.agentsDir}.removed-${opts.timestamp}`;
|
|
338
|
+
fs.renameSync(plan.agentsDir, movedTo);
|
|
339
|
+
result.agentsDir = { path: plan.agentsDir, disposition: 'moved', movedTo };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
result.errors.push(`dispose ${plan.agentsDir}: ${err.message}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { type AccountInfo } from './agents.js';
|
|
2
2
|
import type { AgentId } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* True when a Claude OAuth access token is within the refresh leeway of expiry
|
|
5
|
+
* (or already expired) — i.e. it "would need a refresh" before the next use.
|
|
6
|
+
*
|
|
7
|
+
* Single source of truth for the expiry gate, shared by the two callers that
|
|
8
|
+
* must agree on it but act differently: the run/usage hot path
|
|
9
|
+
* (`getClaudeAccessToken`) refreshes when this is true; the health probe
|
|
10
|
+
* (`probeClaudeStatus`) must NOT refresh and instead reports the non-fatal
|
|
11
|
+
* `expired` state (RUSH-1822). A missing `expiresAt` is treated as "still
|
|
12
|
+
* fresh" (never force a refresh on a token with no known expiry).
|
|
13
|
+
*/
|
|
14
|
+
export declare function claudeAccessTokenNeedsRefresh(expiresAt: number | null | undefined, nowMs?: number): boolean;
|
|
3
15
|
/** Discriminator for usage window types. */
|
|
4
16
|
export type UsageWindowKey = 'session' | 'week' | 'sonnet_week' | 'month';
|
|
5
17
|
/** A single rate-limit window with utilization percentage and reset time. */
|
|
@@ -201,7 +213,7 @@ export interface ProviderProbe {
|
|
|
201
213
|
/** Network/parse error message when status is null but a token was present. */
|
|
202
214
|
error?: string;
|
|
203
215
|
}
|
|
204
|
-
/** Probe Claude's OAuth token against the usage endpoint.
|
|
216
|
+
/** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
|
|
205
217
|
export declare function probeClaudeStatus(home?: string, cliVersion?: string | null): Promise<ProviderProbe>;
|
|
206
218
|
/** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
|
|
207
219
|
export declare function probeKimiStatus(home?: string): Promise<ProviderProbe>;
|
package/dist/lib/usage.js
CHANGED
|
@@ -25,6 +25,22 @@ const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
|
25
25
|
const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
26
26
|
const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
|
|
27
27
|
const CLAUDE_REFRESH_LEEWAY_MS = 5 * 60 * 1000;
|
|
28
|
+
/**
|
|
29
|
+
* True when a Claude OAuth access token is within the refresh leeway of expiry
|
|
30
|
+
* (or already expired) — i.e. it "would need a refresh" before the next use.
|
|
31
|
+
*
|
|
32
|
+
* Single source of truth for the expiry gate, shared by the two callers that
|
|
33
|
+
* must agree on it but act differently: the run/usage hot path
|
|
34
|
+
* (`getClaudeAccessToken`) refreshes when this is true; the health probe
|
|
35
|
+
* (`probeClaudeStatus`) must NOT refresh and instead reports the non-fatal
|
|
36
|
+
* `expired` state (RUSH-1822). A missing `expiresAt` is treated as "still
|
|
37
|
+
* fresh" (never force a refresh on a token with no known expiry).
|
|
38
|
+
*/
|
|
39
|
+
export function claudeAccessTokenNeedsRefresh(expiresAt, nowMs = Date.now()) {
|
|
40
|
+
if (expiresAt == null)
|
|
41
|
+
return false;
|
|
42
|
+
return nowMs + CLAUDE_REFRESH_LEEWAY_MS >= expiresAt;
|
|
43
|
+
}
|
|
28
44
|
const CLAUDE_SCOPES = [
|
|
29
45
|
'user:profile',
|
|
30
46
|
'user:inference',
|
|
@@ -609,26 +625,29 @@ async function getDroidUsageInfo(options) {
|
|
|
609
625
|
return { snapshot: null, error: null };
|
|
610
626
|
}
|
|
611
627
|
}
|
|
612
|
-
/** Probe Claude's OAuth token against the usage endpoint.
|
|
628
|
+
/** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
|
|
613
629
|
export async function probeClaudeStatus(home, cliVersion) {
|
|
614
630
|
const oauth = await loadClaudeOauth(home);
|
|
615
|
-
|
|
631
|
+
const accessToken = oauth?.accessToken?.trim();
|
|
632
|
+
if (!accessToken)
|
|
616
633
|
return { status: null, token: 'missing' };
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
634
|
+
// Never refresh from a health probe. Claude's refresh token is single-use and
|
|
635
|
+
// rotates on every refresh; with one account signed into several machines the
|
|
636
|
+
// daemon's every-3-min fleet-cache warm (probeLocalFleetAuth -> here) would
|
|
637
|
+
// stampede that one rotating token and silently invalidate every other
|
|
638
|
+
// holder, dropping the fleet to "run /login" (RUSH-1822). Mirror the sibling
|
|
639
|
+
// Kimi/Droid probes, which never refresh: if the stored token is within the
|
|
640
|
+
// refresh leeway of expiry, report the non-fatal `expired` state ("would need
|
|
641
|
+
// a refresh") instead of rotating it, and leave the single legitimate refresh
|
|
642
|
+
// to the run/usage hot path (getClaudeAccessToken).
|
|
643
|
+
if (claudeAccessTokenNeedsRefresh(oauth?.expiresAt ?? null)) {
|
|
644
|
+
return { status: null, token: 'expired' };
|
|
623
645
|
}
|
|
624
|
-
// Fall back to the stored (possibly stale) token so the server returns the
|
|
625
|
-
// real verdict — a 401 here IS the revoked/expired signal we want to surface.
|
|
626
|
-
const bearer = access || oauth.accessToken.trim();
|
|
627
646
|
try {
|
|
628
647
|
const response = await fetch(CLAUDE_USAGE_URL, {
|
|
629
648
|
method: 'GET',
|
|
630
649
|
headers: {
|
|
631
|
-
Authorization: `Bearer ${
|
|
650
|
+
Authorization: `Bearer ${accessToken}`,
|
|
632
651
|
'Content-Type': 'application/json',
|
|
633
652
|
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
|
|
634
653
|
'User-Agent': getClaudeUserAgent(cliVersion),
|
|
@@ -1058,8 +1077,7 @@ async function getClaudeAccessToken(oauth, home) {
|
|
|
1058
1077
|
if (!accessToken) {
|
|
1059
1078
|
return null;
|
|
1060
1079
|
}
|
|
1061
|
-
|
|
1062
|
-
if (expiresAt === null || Date.now() + CLAUDE_REFRESH_LEEWAY_MS < expiresAt) {
|
|
1080
|
+
if (!claudeAccessTokenNeedsRefresh(oauth.expiresAt ?? null)) {
|
|
1063
1081
|
return accessToken;
|
|
1064
1082
|
}
|
|
1065
1083
|
if (!oauth.refreshToken) {
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -216,6 +216,22 @@ export declare function getGlobalDefault(agent: AgentId): string | null;
|
|
|
216
216
|
* Set the global default version for an agent.
|
|
217
217
|
*/
|
|
218
218
|
export declare function setGlobalDefault(agent: AgentId, version: string | undefined): void;
|
|
219
|
+
/**
|
|
220
|
+
* Mark an installed version as an isolated install (`agents add --isolated`).
|
|
221
|
+
*
|
|
222
|
+
* Isolated versions are fully self-contained: they never become the global
|
|
223
|
+
* default and never own the user's real `~/.<agent>` config directory. This
|
|
224
|
+
* flag is what keeps every "adopting" code path away from them.
|
|
225
|
+
*/
|
|
226
|
+
export declare function markVersionIsolated(agent: AgentId, version: string): void;
|
|
227
|
+
/**
|
|
228
|
+
* Whether a version was installed as an isolated install (`agents add --isolated`).
|
|
229
|
+
*
|
|
230
|
+
* Used to exclude such versions from global-default promotion and from any
|
|
231
|
+
* flow that would touch the user's real `~/.<agent>` directory, and to gate the
|
|
232
|
+
* `--isolated` safety check on `agents remove`.
|
|
233
|
+
*/
|
|
234
|
+
export declare function isVersionIsolated(agent: AgentId, version: string): boolean;
|
|
219
235
|
/**
|
|
220
236
|
* Install a specific version of an agent.
|
|
221
237
|
*/
|
package/dist/lib/versions.js
CHANGED
|
@@ -1148,6 +1148,37 @@ export function setGlobalDefault(agent, version) {
|
|
|
1148
1148
|
}
|
|
1149
1149
|
writeMeta(meta);
|
|
1150
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Path to the sentinel file that marks a version as an isolated install.
|
|
1153
|
+
*
|
|
1154
|
+
* It lives at the version-dir root (a sibling of `home/`), so it is carried
|
|
1155
|
+
* along when `softDeleteVersionDir` moves the whole directory to trash and is
|
|
1156
|
+
* restored intact by `agents trash restore`. Its mere presence is the marker;
|
|
1157
|
+
* the contents are an informational timestamp only.
|
|
1158
|
+
*/
|
|
1159
|
+
function getIsolatedMarkerPath(agent, version) {
|
|
1160
|
+
return path.join(getVersionDir(agent, version), '.isolated');
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Mark an installed version as an isolated install (`agents add --isolated`).
|
|
1164
|
+
*
|
|
1165
|
+
* Isolated versions are fully self-contained: they never become the global
|
|
1166
|
+
* default and never own the user's real `~/.<agent>` config directory. This
|
|
1167
|
+
* flag is what keeps every "adopting" code path away from them.
|
|
1168
|
+
*/
|
|
1169
|
+
export function markVersionIsolated(agent, version) {
|
|
1170
|
+
fs.writeFileSync(getIsolatedMarkerPath(agent, version), `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Whether a version was installed as an isolated install (`agents add --isolated`).
|
|
1174
|
+
*
|
|
1175
|
+
* Used to exclude such versions from global-default promotion and from any
|
|
1176
|
+
* flow that would touch the user's real `~/.<agent>` directory, and to gate the
|
|
1177
|
+
* `--isolated` safety check on `agents remove`.
|
|
1178
|
+
*/
|
|
1179
|
+
export function isVersionIsolated(agent, version) {
|
|
1180
|
+
return fs.existsSync(getIsolatedMarkerPath(agent, version));
|
|
1181
|
+
}
|
|
1151
1182
|
/**
|
|
1152
1183
|
* Grok's official installer writes into ~/.grok/downloads, which (because we
|
|
1153
1184
|
* symlink ~/.grok to the active version home) resolves to the PREVIOUS default
|
|
@@ -1676,14 +1707,20 @@ export function removeVersion(agent, version) {
|
|
|
1676
1707
|
// broke every launcher after removing the pinned default.
|
|
1677
1708
|
if (getGlobalDefault(agent) === version) {
|
|
1678
1709
|
const remaining = listInstalledVersions(agent);
|
|
1679
|
-
|
|
1680
|
-
|
|
1710
|
+
// Never auto-promote an isolated install to the global default: it was
|
|
1711
|
+
// installed to stay separate from the user's setup, and promoting it would
|
|
1712
|
+
// silently make `<agent>` resolve to it (and let a later `use` adopt its
|
|
1713
|
+
// home into `~/.<agent>`). Prefer the newest NON-isolated survivor; if every
|
|
1714
|
+
// survivor is isolated, clear the default rather than adopt one.
|
|
1715
|
+
const promotable = remaining.filter((v) => !isVersionIsolated(agent, v));
|
|
1716
|
+
if (promotable.length > 0) {
|
|
1717
|
+
const newestRemaining = promotable[promotable.length - 1];
|
|
1681
1718
|
setGlobalDefault(agent, newestRemaining);
|
|
1682
1719
|
console.log(chalk.yellow(`Default ${agent} was ${version} (removed); reassigned to ${newestRemaining}. Change it with: agents use ${agent}@<version>`));
|
|
1683
1720
|
}
|
|
1684
1721
|
else {
|
|
1685
1722
|
setGlobalDefault(agent, undefined);
|
|
1686
|
-
console.log(chalk.yellow(`Removed the last
|
|
1723
|
+
console.log(chalk.yellow(`Removed the last non-isolated ${agent} version and cleared its default. Reinstall with: agents add ${agent}, then set one with: agents use ${agent}@<version>`));
|
|
1687
1724
|
}
|
|
1688
1725
|
}
|
|
1689
1726
|
// Clean up dangling config symlink if it pointed to the removed version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.72",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|