@event4u/agent-switch 2.0.0 → 2.1.0
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/README.md +33 -0
- package/dist/config-transfer.js +12 -2
- package/dist/doctor.js +23 -2
- package/dist/index.js +244 -23
- package/dist/profiles.js +20 -4
- package/dist/providers.js +3 -0
- package/dist/release-notes.js +154 -0
- package/dist/sessions.js +19 -0
- package/dist/shellenv.js +38 -4
- package/dist/single-load.js +171 -0
- package/dist/tooling.js +73 -0
- package/dist/updates.js +37 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,39 @@ Most naive switchers snapshot the macOS Keychain entry (`Claude Code-credentials
|
|
|
29
29
|
|
|
30
30
|
The same restraint governs the whole tool: no proxy, no traffic interception, no fingerprinting, no prompt rewriting — see [NON-GOALS.md](NON-GOALS.md) for the explicit list and the alternatives this ecosystem offers instead.
|
|
31
31
|
|
|
32
|
+
## One profile, one session
|
|
33
|
+
|
|
34
|
+
Because `agent-switch use` sets a **global** pointer that every shell re-reads,
|
|
35
|
+
nothing used to stop two terminals from starting `claude` on the same account —
|
|
36
|
+
sharing one set of rate limits and both refreshing one rotating OAuth token,
|
|
37
|
+
while each terminal looked like a separate account.
|
|
38
|
+
|
|
39
|
+
The single-load guard refuses that second start and says which session already
|
|
40
|
+
holds the profile and which accounts are free:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
$ claude
|
|
44
|
+
agent-switch: profile "work" is already loaded by a running claude session.
|
|
45
|
+
|
|
46
|
+
pid 41234 started 12m ago ~/projects/foo
|
|
47
|
+
|
|
48
|
+
Two sessions on one account share its rate limits, and both refresh the
|
|
49
|
+
same rotating OAuth token.
|
|
50
|
+
|
|
51
|
+
Ways forward:
|
|
52
|
+
agent-switch use privat switch to a free account — also free: event4u
|
|
53
|
+
AGENT_SWITCH_ALLOW_DUPLICATE=1 claude start it anyway, this once
|
|
54
|
+
agent-switch guard warn warn instead of refusing, from now on
|
|
55
|
+
agent-switch guard off turn the guard off entirely
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It never gets in the way where it has no business being: a `claude` started
|
|
59
|
+
*inside* a running session is a child, not a duplicate; a crashed session leaves
|
|
60
|
+
nothing behind that can lock you out; and if `agent-switch` itself is missing or
|
|
61
|
+
broken, the wrapper falls straight through to the real binary. Set the policy
|
|
62
|
+
with `agent-switch guard <block|warn|off>` — see the
|
|
63
|
+
[CLI reference](https://event4u-app.github.io/agent-switch/reference/cli/#single-load-guard).
|
|
64
|
+
|
|
32
65
|
## Install
|
|
33
66
|
|
|
34
67
|
**npm / npx** (any OS — needs Node ≥ 20). The package is published as
|
package/dist/config-transfer.js
CHANGED
|
@@ -36,9 +36,19 @@ const DENY_SET = new Set([".claude.json", ".credentials.json", "plugins", "sessi
|
|
|
36
36
|
export const MAX_ENTRIES = 5000;
|
|
37
37
|
/** Bound untrusted input: refuse a single decoded file larger than this. */
|
|
38
38
|
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
39
|
-
export const FULL_REFUSAL = "--full (bundling live OAuth tokens) is intentionally not implemented — it is an account-takeover vector; export is config-only by design."
|
|
39
|
+
export const FULL_REFUSAL = "--full (bundling live OAuth tokens) is intentionally not implemented — it is an account-takeover vector; export is config-only by design. " +
|
|
40
|
+
"To move an account to another machine: `agent-switch config export` (config-only) → `config import` on the target → `agent-switch <profile>` and re-run `claude` to log in there — a fresh login is machine-specific by design.";
|
|
40
41
|
/** Refuse the `--full` (credential-bundling) variant. Throws so callers can
|
|
41
|
-
* surface it via `die`; kept here because it is a config-transfer policy.
|
|
42
|
+
* surface it via `die`; kept here because it is a config-transfer policy.
|
|
43
|
+
*
|
|
44
|
+
* Re-affirmed 2026-07-29: an AI-council threat-modelled review of whether to
|
|
45
|
+
* build `--full` (with mandatory encryption) concluded KEEP IT REFUSED — even
|
|
46
|
+
* AES-256-GCM + Argon2id cannot fix the threat-model shift (an exportable
|
|
47
|
+
* credential bundle is a persistent cloud-sync / malware / phishing exfil
|
|
48
|
+
* vector), the 30-second `config import → auth login` path covers the real
|
|
49
|
+
* need, and industry practice (aws / gh / kubectl) never exports credentials.
|
|
50
|
+
* Reversing this requires the repo owner's explicit sign-off, not agent
|
|
51
|
+
* convergence. */
|
|
42
52
|
export function assertNotFull(full) {
|
|
43
53
|
if (full)
|
|
44
54
|
throw new Error(FULL_REFUSAL);
|
package/dist/doctor.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
-
import { ROOT, activeFor, configDir, listProfiles, profileExists } from "./profiles.js";
|
|
10
|
+
import { ROOT, activeFor, configDir, listProfiles, profileExists, readGuard } from "./profiles.js";
|
|
11
11
|
import { allProviders } from "./providers.js";
|
|
12
12
|
import { credentialStore } from "./credentials.js";
|
|
13
13
|
import { sharedLinkHealth } from "./share.js";
|
|
14
|
-
import { checkAuth } from "./api.js";
|
|
14
|
+
import { checkAuth, liveSessionPids } from "./api.js";
|
|
15
15
|
import { checkTooling, doctorToolingLine } from "./tooling.js";
|
|
16
16
|
const OK = "✅";
|
|
17
17
|
const WARN = "⚠️";
|
|
@@ -113,6 +113,27 @@ export async function runDoctor() {
|
|
|
113
113
|
line(WARN, `claude/${n}: ${parts.join("; ")} — run \`agent-switch share sync\`.`);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
// Single-load guard: the policy, and whether any profile is doubled RIGHT NOW.
|
|
117
|
+
// Reported even when the policy is `warn`/`off` — the point of doctor is to
|
|
118
|
+
// surface the state, and a silently tolerated duplicate is exactly the thing
|
|
119
|
+
// the user came here to find.
|
|
120
|
+
{
|
|
121
|
+
const policy = readGuard();
|
|
122
|
+
line(policy === "off" ? WARN : OK, `single-load guard: ${policy}${policy === "off" ? " — a profile can be loaded twice" : ""}.`);
|
|
123
|
+
const doubled = [];
|
|
124
|
+
for (const p of allProviders()) {
|
|
125
|
+
if (!p.hasLiveSessionSignal)
|
|
126
|
+
continue;
|
|
127
|
+
for (const n of listProfiles(p.id)) {
|
|
128
|
+
const count = liveSessionPids(configDir(p.id, n)).length;
|
|
129
|
+
if (count > 1)
|
|
130
|
+
doubled.push(`${p.id}/${n} (${count} sessions)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (doubled.length > 0) {
|
|
134
|
+
line(WARN, ` loaded more than once: ${doubled.join(", ")} — they share one account's rate limits.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
116
137
|
// Ecosystem tooling (agent-config + rtk) — same detection module as
|
|
117
138
|
// `agent-switch tooling`, never a second implementation. Both tools are
|
|
118
139
|
// optional, so an unhealthy row is a WARN, never a blocking error.
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import * as readline from "node:readline";
|
|
17
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
-
import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
|
|
18
|
+
import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readGuard, setGuard, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
|
|
19
19
|
import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
|
|
20
20
|
import { rebind, restoreRebind } from "./rebind.js";
|
|
21
21
|
import { buildRebindDialog, resolveRebindChoice, selectableRows } from "./rebind-dialog.js";
|
|
@@ -27,17 +27,19 @@ import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./sh
|
|
|
27
27
|
import { assertNotFull, exportConfig, importConfig } from "./config-transfer.js";
|
|
28
28
|
import { detectShell, shellenvScript } from "./shellenv.js";
|
|
29
29
|
import { runDoctor } from "./doctor.js";
|
|
30
|
-
import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.js";
|
|
30
|
+
import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
|
|
31
|
+
import { releaseNoteBlocks } from "./release-notes.js";
|
|
31
32
|
import { readHistory, recordHistorySample } from "./history.js";
|
|
32
33
|
import { launchGui } from "./gui-launch.js";
|
|
33
|
-
import {
|
|
34
|
+
import { GUARD_POLICIES, GUARD_REFUSED_EXIT, OVERRIDE_ENV, guardVerdict, inspectLoad, isGuardPolicy, overrideFromEnv, renderGuardMessage, } from "./single-load.js";
|
|
35
|
+
import { checkForUpdate, fetchReleases, releasesNewerThan, selfUpdate } from "./updates.js";
|
|
34
36
|
import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
|
|
35
37
|
import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
|
|
36
38
|
import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
|
|
37
39
|
import { formatSnapshot, parseUsage } from "./usage.js";
|
|
38
40
|
import { readCodexUsage } from "./codex-usage.js";
|
|
39
41
|
import { redeemResetCredit } from "./codex-reset.js";
|
|
40
|
-
import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
|
|
42
|
+
import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
|
|
41
43
|
import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
|
|
42
44
|
import { cmdService, serviceUninstall } from "./service.js";
|
|
43
45
|
import { readContext, turnInFlight } from "./telemetry.js";
|
|
@@ -52,6 +54,46 @@ import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmux
|
|
|
52
54
|
const credentials = credentialStore();
|
|
53
55
|
const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
|
|
54
56
|
const CLAUDE_JSON = path.join(HOME, ".claude.json");
|
|
57
|
+
/** Same-provider profiles that carry no live session — the concrete
|
|
58
|
+
* alternatives a refusal names, so the user can act on it without first
|
|
59
|
+
* running `list` themselves. */
|
|
60
|
+
function freeProfiles(providerId, exclude) {
|
|
61
|
+
return listProfiles(providerId).filter((n) => n !== exclude && liveSessionPids(configDir(providerId, n)).length === 0);
|
|
62
|
+
}
|
|
63
|
+
function checkSingleLoad(providerId, name, opts = {}) {
|
|
64
|
+
const p = provider(providerId);
|
|
65
|
+
// Early-out before touching the filesystem: `dir --guard` runs on every
|
|
66
|
+
// interactive start, so a disabled guard (or a provider with no live-session
|
|
67
|
+
// signal at all) must cost a state read and nothing more.
|
|
68
|
+
const policy = readGuard();
|
|
69
|
+
if (policy === "off" || !p.hasLiveSessionSignal) {
|
|
70
|
+
return { verdict: "allow", inspection: { sessions: [], loaded: false, nested: false }, message: null };
|
|
71
|
+
}
|
|
72
|
+
const cfg = configDir(providerId, name);
|
|
73
|
+
const inspection = inspectLoad(cfg, p.envVar);
|
|
74
|
+
const verdict = guardVerdict({
|
|
75
|
+
policy,
|
|
76
|
+
inspection,
|
|
77
|
+
overridden: opts.forced === true || overrideFromEnv(),
|
|
78
|
+
detectable: p.hasLiveSessionSignal,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
verdict,
|
|
82
|
+
inspection,
|
|
83
|
+
message: verdict === "allow"
|
|
84
|
+
? null
|
|
85
|
+
: renderGuardMessage({
|
|
86
|
+
profile: name,
|
|
87
|
+
binary: p.binary,
|
|
88
|
+
verdict,
|
|
89
|
+
inspection,
|
|
90
|
+
offerForceFlag: opts.offerForceFlag === true,
|
|
91
|
+
context: opts.context ?? "start",
|
|
92
|
+
// Only worth computing on a message we are actually going to print.
|
|
93
|
+
alternatives: verdict === "refuse" ? freeProfiles(providerId, name) : [],
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
55
97
|
// ---------- launcher ---------------------------------------------------------
|
|
56
98
|
function launch(p, name, args) {
|
|
57
99
|
const home = configDir(p.id, name);
|
|
@@ -165,6 +207,14 @@ function cmdUse(providerId, name) {
|
|
|
165
207
|
const id = identity(providerId, n);
|
|
166
208
|
console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
|
|
167
209
|
console.log(`New \`${provider(providerId).binary}\` sessions use this profile. Running sessions are unaffected.`);
|
|
210
|
+
// `use` loads nothing — it states an intent — so it NEVER blocks. But it is
|
|
211
|
+
// the cheapest possible moment to tell the user that the profile they just
|
|
212
|
+
// pointed at is already busy, before they type the command that gets refused.
|
|
213
|
+
const check = checkSingleLoad(providerId, n, { context: "use" });
|
|
214
|
+
if (check.message) {
|
|
215
|
+
console.log("");
|
|
216
|
+
console.warn(check.message);
|
|
217
|
+
}
|
|
168
218
|
}
|
|
169
219
|
function cmdDeactivate(providerId) {
|
|
170
220
|
const active = activeFor(providerId);
|
|
@@ -179,9 +229,18 @@ function cmdDeactivate(providerId) {
|
|
|
179
229
|
function cmdRun(providerId, name, args) {
|
|
180
230
|
const n = requireProfile(providerId, name, "run");
|
|
181
231
|
const p = provider(providerId);
|
|
182
|
-
// `--tmux`
|
|
232
|
+
// `--tmux` and `--force` are agent-switch flags, not passthrough flags — strip
|
|
233
|
+
// them before the rest reaches the provider binary.
|
|
183
234
|
const wantTmux = args.includes("--tmux");
|
|
184
|
-
const
|
|
235
|
+
const forced = args.includes("--force");
|
|
236
|
+
const passthrough = args.filter((a) => a !== "--tmux" && a !== "--force");
|
|
237
|
+
// agent-switch spawns the binary itself here, so this is the strongest
|
|
238
|
+
// enforcement point: refuse before anything is launched.
|
|
239
|
+
const guard = checkSingleLoad(providerId, n, { forced, offerForceFlag: true });
|
|
240
|
+
if (guard.message)
|
|
241
|
+
console.error(guard.verdict === "refuse" ? `error: ${guard.message}` : guard.message);
|
|
242
|
+
if (guard.verdict === "refuse")
|
|
243
|
+
process.exit(GUARD_REFUSED_EXIT);
|
|
185
244
|
if (wantTmux) {
|
|
186
245
|
if (!tmuxAvailable()) {
|
|
187
246
|
console.log("(--tmux unavailable — tmux not found or Windows; running normally.)");
|
|
@@ -204,8 +263,12 @@ function printProfileLine(providerId, name, showLive) {
|
|
|
204
263
|
let live = "";
|
|
205
264
|
if (showLive && providerId === "claude") {
|
|
206
265
|
const pids = liveSessionPids(configDir("claude", name));
|
|
207
|
-
|
|
208
|
-
|
|
266
|
+
// More than one session on one account is the state the single-load guard
|
|
267
|
+
// exists to prevent, so it is marked rather than blending into the count.
|
|
268
|
+
if (pids.length > 1)
|
|
269
|
+
live = ` ⚠️ [loaded ${pids.length}× — ${pids.length} live sessions]`;
|
|
270
|
+
else if (pids.length === 1)
|
|
271
|
+
live = " [1 live session]";
|
|
209
272
|
}
|
|
210
273
|
console.log(`${mark} ${name.padEnd(16)} ${id}${live}`);
|
|
211
274
|
}
|
|
@@ -362,17 +425,37 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
362
425
|
}
|
|
363
426
|
}
|
|
364
427
|
}
|
|
365
|
-
|
|
366
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Resolve which profile a shell wrapper should load: directory mapping (nearest
|
|
430
|
+
* ancestor of CWD) > active-for-provider. Empty output → the wrapper falls back
|
|
431
|
+
* to the default config dir.
|
|
432
|
+
*
|
|
433
|
+
* With `--guard`, stdout is unchanged (the wrapper still reads the path from
|
|
434
|
+
* it) and the guard speaks on STDERR, exiting {@link GUARD_REFUSED_EXIT} on a
|
|
435
|
+
* refusal. Splitting the streams is what lets one command both feed the wrapper
|
|
436
|
+
* and talk to the human; a distinct exit code is what lets the wrapper block on
|
|
437
|
+
* a refusal without ever blocking on a missing or older `agent-switch`.
|
|
438
|
+
*/
|
|
439
|
+
function cmdDir(providerId, guarded = false) {
|
|
367
440
|
const mapped = resolveMapping(process.cwd(), providerId);
|
|
368
|
-
|
|
369
|
-
|
|
441
|
+
const name = mapped && profileExists(providerId, mapped.name)
|
|
442
|
+
? mapped.name
|
|
443
|
+
: (() => {
|
|
444
|
+
const active = activeFor(providerId);
|
|
445
|
+
return active && profileExists(providerId, active) ? active : null;
|
|
446
|
+
})();
|
|
447
|
+
if (!name)
|
|
370
448
|
return;
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
449
|
+
console.log(configDir(providerId, name));
|
|
450
|
+
if (!guarded)
|
|
451
|
+
return;
|
|
452
|
+
// A wrapper cannot pass `--force` (it would reach the provider binary), so
|
|
453
|
+
// only the env override is offered here.
|
|
454
|
+
const guard = checkSingleLoad(providerId, name, { offerForceFlag: false });
|
|
455
|
+
if (guard.message)
|
|
456
|
+
console.error(`agent-switch: ${guard.message}`);
|
|
457
|
+
if (guard.verdict === "refuse")
|
|
458
|
+
process.exit(GUARD_REFUSED_EXIT);
|
|
376
459
|
}
|
|
377
460
|
function cmdMap(providerId, name, dir) {
|
|
378
461
|
const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile> [dir]`);
|
|
@@ -1664,6 +1747,63 @@ function cmdOpen(appId, name) {
|
|
|
1664
1747
|
console.log("is saved to this profile. Quit other windows of this app before signing in.");
|
|
1665
1748
|
}
|
|
1666
1749
|
}
|
|
1750
|
+
/**
|
|
1751
|
+
* `agent-switch guard [block|warn|off]` — read or set the single-load policy.
|
|
1752
|
+
* The bare form also reports what is loaded RIGHT NOW, because "what is my
|
|
1753
|
+
* policy" and "is anything currently doubled" are the same question in practice.
|
|
1754
|
+
*/
|
|
1755
|
+
function cmdGuard(value, json = false) {
|
|
1756
|
+
if (value !== undefined) {
|
|
1757
|
+
if (!isGuardPolicy(value)) {
|
|
1758
|
+
die(`unknown guard policy "${value}" (choose: ${GUARD_POLICIES.join(", ")})`);
|
|
1759
|
+
}
|
|
1760
|
+
setGuard(value);
|
|
1761
|
+
}
|
|
1762
|
+
const policy = readGuard();
|
|
1763
|
+
// Every profile of every provider that can report a live session.
|
|
1764
|
+
const rows = [];
|
|
1765
|
+
for (const pid of PROVIDER_IDS) {
|
|
1766
|
+
if (!provider(pid).hasLiveSessionSignal)
|
|
1767
|
+
continue;
|
|
1768
|
+
for (const n of listProfiles(pid)) {
|
|
1769
|
+
const count = liveSessionPids(configDir(pid, n)).length;
|
|
1770
|
+
if (count > 0)
|
|
1771
|
+
rows.push({ provider: pid, name: n, sessions: count });
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
if (json) {
|
|
1775
|
+
console.log(JSON.stringify({ policy, loaded: rows }, null, 2));
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const explain = {
|
|
1779
|
+
block: "a second session on a profile that is already loaded is refused",
|
|
1780
|
+
warn: "a second session is reported, then allowed",
|
|
1781
|
+
off: "no check at all",
|
|
1782
|
+
};
|
|
1783
|
+
console.log(`Single-load guard: ${policy} — ${explain[policy]}.`);
|
|
1784
|
+
if (rows.length === 0) {
|
|
1785
|
+
console.log("No profile is currently loaded by a live session.");
|
|
1786
|
+
}
|
|
1787
|
+
else {
|
|
1788
|
+
console.log("");
|
|
1789
|
+
for (const r of rows) {
|
|
1790
|
+
const doubled = r.sessions > 1;
|
|
1791
|
+
const mark = doubled ? "⚠️ " : " ";
|
|
1792
|
+
const detail = doubled ? `loaded ${r.sessions}× — this is what the guard exists to prevent` : "1 live session";
|
|
1793
|
+
console.log(`${mark} ${`${r.provider}/${r.name}`.padEnd(24)} ${detail}`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
const inert = PROVIDER_IDS.filter((pid) => !provider(pid).hasLiveSessionSignal && listProfiles(pid).length > 0);
|
|
1797
|
+
if (inert.length > 0) {
|
|
1798
|
+
console.log("");
|
|
1799
|
+
console.log(`Not covered: ${inert.join(", ")} — these CLIs do not register a running session on disk,`);
|
|
1800
|
+
console.log("so agent-switch has no way to tell a second load from a first.");
|
|
1801
|
+
}
|
|
1802
|
+
if (policy !== "off") {
|
|
1803
|
+
console.log("");
|
|
1804
|
+
console.log(`Override once: ${OVERRIDE_ENV}=1 <binary> · Change: agent-switch guard <${GUARD_POLICIES.join("|")}>`);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1667
1807
|
function cmdShellenv(shellArg) {
|
|
1668
1808
|
console.log(shellenvScript(detectShell(shellArg)));
|
|
1669
1809
|
}
|
|
@@ -1679,6 +1819,61 @@ function cmdTooling(json = false) {
|
|
|
1679
1819
|
console.log(`agent-switch tooling — platform ${process.platform}\n`);
|
|
1680
1820
|
for (const l of formatToolingLines(tools))
|
|
1681
1821
|
console.log(l);
|
|
1822
|
+
// This readout stays offline by contract; the notes command is where the
|
|
1823
|
+
// network lives, so point at it rather than fetching here.
|
|
1824
|
+
console.log("\nWhat changed in a release: `agent-switch tooling notes [<tool>]`");
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* `agent-switch tooling notes [<tool>] [--latest] [--json]` — read what an
|
|
1828
|
+
* ecosystem update actually contains, in the terminal.
|
|
1829
|
+
*
|
|
1830
|
+
* Deliberately a SEPARATE subcommand: `tooling` (no args) is a local, offline
|
|
1831
|
+
* readout the GUI consumes over `--json`, and that contract stays. This one
|
|
1832
|
+
* fetches, and says so in the help.
|
|
1833
|
+
*
|
|
1834
|
+
* Default tool is `agent-config` (the one agent-switch announces updates for).
|
|
1835
|
+
* Shown: every release newer than the installed version, newest first — so
|
|
1836
|
+
* someone several versions behind reads all of them, not just the last. Up to
|
|
1837
|
+
* date or not installed → the newest release, so the command always answers.
|
|
1838
|
+
* A tool with no verified release source gets a named refusal, never an
|
|
1839
|
+
* invented source.
|
|
1840
|
+
*/
|
|
1841
|
+
async function cmdToolingNotes(id, flags) {
|
|
1842
|
+
const toolId = (id ?? "agent-config");
|
|
1843
|
+
if (!TOOL_IDS.includes(toolId)) {
|
|
1844
|
+
die(`usage: agent-switch tooling notes [<${TOOL_IDS.join("|")}>] [--latest] [--json]`);
|
|
1845
|
+
}
|
|
1846
|
+
const repo = toolReleaseRepo(toolId);
|
|
1847
|
+
if (!repo)
|
|
1848
|
+
die(toolNotesRefusal(toolId));
|
|
1849
|
+
const installed = checkTooling({ ids: [toolId] })[0]?.version ?? null;
|
|
1850
|
+
let releases = [];
|
|
1851
|
+
let failure = null;
|
|
1852
|
+
try {
|
|
1853
|
+
releases = await fetchReleases(repo);
|
|
1854
|
+
}
|
|
1855
|
+
catch (e) {
|
|
1856
|
+
failure = e instanceof Error ? e.message : String(e);
|
|
1857
|
+
}
|
|
1858
|
+
const latest = releases[0]?.tag ?? null;
|
|
1859
|
+
// Unread first; when there is nothing unread (up to date, not installed, or
|
|
1860
|
+
// the list is empty) fall back to the newest release so the command answers.
|
|
1861
|
+
const unread = installed ? releasesNewerThan(releases, installed) : [];
|
|
1862
|
+
const selected = unread.length > 0 ? unread : releases.slice(0, 1);
|
|
1863
|
+
const view = { id: toolId, installed, latest, releases: flags.latest ? selected.slice(0, 1) : selected };
|
|
1864
|
+
if (flags.json) {
|
|
1865
|
+
console.log(JSON.stringify({
|
|
1866
|
+
...view,
|
|
1867
|
+
repo,
|
|
1868
|
+
error: failure,
|
|
1869
|
+
releases: view.releases.map((r) => ({ ...r, blocks: releaseNoteBlocks(r.notes) })),
|
|
1870
|
+
}, null, 2));
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
for (const line of formatToolNotes(view))
|
|
1874
|
+
console.log(line);
|
|
1875
|
+
if (failure)
|
|
1876
|
+
console.log(`\nCould not reach the release source (${failure}) — showing what was available.`);
|
|
1682
1877
|
}
|
|
1683
1878
|
/** `agent-switch tooling install|upgrade <id>` — run the tool's per-platform
|
|
1684
1879
|
* install/upgrade command as a visible child process (the GUI runs this inside
|
|
@@ -1736,7 +1931,8 @@ Run with no command to launch the tray/menubar GUI (single-instance).
|
|
|
1736
1931
|
agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
|
|
1737
1932
|
agent-switch current [--provider P] show the active profile(s)
|
|
1738
1933
|
agent-switch whoami [--provider P] [name] show a profile's account identity
|
|
1739
|
-
agent-switch dir [--provider P]
|
|
1934
|
+
agent-switch dir [--provider P] [--guard] resolve profile for CWD (mapping > active)
|
|
1935
|
+
agent-switch guard [block|warn|off] one profile, one live session (default block)
|
|
1740
1936
|
agent-switch map [--provider P] <name> [dir] map a directory to a profile
|
|
1741
1937
|
agent-switch unmap [--provider P] [dir] remove a directory mapping
|
|
1742
1938
|
agent-switch mappings list directory mappings
|
|
@@ -1769,6 +1965,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
|
|
|
1769
1965
|
agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
|
|
1770
1966
|
agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
|
|
1771
1967
|
agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
|
|
1968
|
+
agent-switch tooling notes [<tool>] [--latest] [--json] read an ecosystem tool's release notes (default agent-config; fetches from GitHub)
|
|
1772
1969
|
agent-switch doctor per-OS, per-provider self-check`);
|
|
1773
1970
|
}
|
|
1774
1971
|
/**
|
|
@@ -1858,7 +2055,26 @@ async function rebindDialog(running) {
|
|
|
1858
2055
|
async function cmdRebind(providerId, providerExplicit, account, flags) {
|
|
1859
2056
|
if (providerExplicit && providerId !== "claude")
|
|
1860
2057
|
die(`rebind supports claude only (not ${providerId})`);
|
|
1861
|
-
|
|
2058
|
+
// Resolve which running profile rebind targets: explicit --profile wins; else
|
|
2059
|
+
// prefer the profile that actually has a LIVE session (rebind's whole point is
|
|
2060
|
+
// the running session, which may not be the "active" default-for-new-processes
|
|
2061
|
+
// profile). Only auto-target when exactly one profile is running — a credential
|
|
2062
|
+
// write must never guess across ambiguity — otherwise keep the active fallback.
|
|
2063
|
+
const explicitProfile = typeof flags.profile === "string" ? flags.profile : undefined;
|
|
2064
|
+
let profile = explicitProfile ?? activeFor("claude") ?? undefined;
|
|
2065
|
+
if (!explicitProfile) {
|
|
2066
|
+
const running = detectRunningClaudeProfile(listProfiles("claude"), (p) => configDir("claude", p));
|
|
2067
|
+
if (running.kind === "single") {
|
|
2068
|
+
if (running.profile !== profile) {
|
|
2069
|
+
console.log(`Auto-detected the running claude profile "${running.profile}" (its session is live) — targeting it. Pass --profile to override.`);
|
|
2070
|
+
}
|
|
2071
|
+
profile = running.profile;
|
|
2072
|
+
}
|
|
2073
|
+
else if (running.kind === "ambiguous") {
|
|
2074
|
+
console.log(`Multiple running claude profiles (${running.profiles.join(", ")}) — ` +
|
|
2075
|
+
`${profile ? `using the active profile "${profile}"` : "no active default"}; pass --profile <name> to target a specific one.`);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
1862
2078
|
if (flags.reset) {
|
|
1863
2079
|
// Kill-switch reset (ADR-003): clear `disabled` + the failure counter so a
|
|
1864
2080
|
// circuit-breaker trip (or a manual disable) no longer refuses rebind.
|
|
@@ -1920,8 +2136,10 @@ async function main() {
|
|
|
1920
2136
|
const moved = migrateLegacyLayout();
|
|
1921
2137
|
if (moved.length > 0) {
|
|
1922
2138
|
// stderr, not stdout: `dir` is machine-consumed by the shell wrapper
|
|
1923
|
-
// (`dir="$(agent-switch dir
|
|
1924
|
-
// would pollute the resolved config path.
|
|
2139
|
+
// (`dir="$(agent-switch dir --provider … --guard)"`), so a status line on
|
|
2140
|
+
// stdout would pollute the resolved config path. Since the single-load
|
|
2141
|
+
// guard needs to speak, the wrapper no longer discards stderr — this
|
|
2142
|
+
// one-time migration note is now visible to the user, which is right.
|
|
1925
2143
|
console.error(`Migrated ${moved.length} Claude profile(s) to the new layout: ${moved.join(", ")}.`);
|
|
1926
2144
|
}
|
|
1927
2145
|
}
|
|
@@ -1956,7 +2174,8 @@ async function main() {
|
|
|
1956
2174
|
case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
|
|
1957
2175
|
case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
|
|
1958
2176
|
case "whoami": return cmdWhoami(providerId, positional[0]);
|
|
1959
|
-
case "dir": return cmdDir(providerId);
|
|
2177
|
+
case "dir": return cmdDir(providerId, !!flags.guard);
|
|
2178
|
+
case "guard": return cmdGuard(positional[0], !!flags.json);
|
|
1960
2179
|
case "map": return cmdMap(providerId, positional[0], positional[1]);
|
|
1961
2180
|
case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
|
|
1962
2181
|
case "mappings": return cmdMappings();
|
|
@@ -1985,8 +2204,10 @@ async function main() {
|
|
|
1985
2204
|
if (positional[0] === "install" || positional[0] === "upgrade") {
|
|
1986
2205
|
return cmdToolingAction(positional[0], positional[1]);
|
|
1987
2206
|
}
|
|
2207
|
+
if (positional[0] === "notes")
|
|
2208
|
+
return cmdToolingNotes(positional[1], flags);
|
|
1988
2209
|
if (positional[0] !== undefined) {
|
|
1989
|
-
die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [--json]`);
|
|
2210
|
+
die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [notes [<tool>]] [--json]`);
|
|
1990
2211
|
}
|
|
1991
2212
|
return cmdTooling(!!flags.json);
|
|
1992
2213
|
case "doctor": return process.exit(await runDoctor());
|
package/dist/profiles.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { PROVIDER_IDS, provider, isProviderId } from "./providers.js";
|
|
6
6
|
import { credentialStore } from "./credentials.js";
|
|
7
|
+
import { DEFAULT_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
|
|
7
8
|
export const HOME = os.homedir();
|
|
8
9
|
export const ROOT = process.env.AGENT_SWITCH_HOME ?? path.join(HOME, ".agent-switch");
|
|
9
10
|
export const STATE_FILE = path.join(ROOT, "state.json");
|
|
@@ -160,6 +161,9 @@ function normalizeRebindState(raw) {
|
|
|
160
161
|
const consecutiveFailures = typeof r.consecutiveFailures === "number" && r.consecutiveFailures >= 0 ? Math.floor(r.consecutiveFailures) : 0;
|
|
161
162
|
return { disabled: r.disabled === true, consecutiveFailures };
|
|
162
163
|
}
|
|
164
|
+
function normalizeGuard(raw) {
|
|
165
|
+
return isGuardPolicy(raw) ? raw : DEFAULT_GUARD_POLICY;
|
|
166
|
+
}
|
|
163
167
|
export function readState() {
|
|
164
168
|
try {
|
|
165
169
|
const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
@@ -169,19 +173,20 @@ export function readState() {
|
|
|
169
173
|
const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
|
|
170
174
|
const osNotifications = raw?.osNotifications === true;
|
|
171
175
|
const rebind = normalizeRebindState(raw?.rebind);
|
|
176
|
+
const guard = normalizeGuard(raw?.guard);
|
|
172
177
|
// v1: { active: "<name>" } — a single Claude profile.
|
|
173
178
|
if (typeof raw?.active === "string") {
|
|
174
|
-
return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
|
|
179
|
+
return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
175
180
|
}
|
|
176
181
|
if (raw?.active && typeof raw.active === "object") {
|
|
177
|
-
return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
|
|
182
|
+
return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
178
183
|
}
|
|
179
|
-
return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
|
|
184
|
+
return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
180
185
|
}
|
|
181
186
|
catch {
|
|
182
187
|
/* absent / unparsable → default */
|
|
183
188
|
}
|
|
184
|
-
return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE } };
|
|
189
|
+
return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE }, guard: DEFAULT_GUARD_POLICY };
|
|
185
190
|
}
|
|
186
191
|
export function readOsNotifications() {
|
|
187
192
|
return readState().osNotifications;
|
|
@@ -191,6 +196,17 @@ export function setOsNotifications(on) {
|
|
|
191
196
|
state.osNotifications = on;
|
|
192
197
|
writeState(state);
|
|
193
198
|
}
|
|
199
|
+
// ---------- single-load guard policy ----------
|
|
200
|
+
/** How the single-load guard reacts to a second load of one profile. */
|
|
201
|
+
export function readGuard() {
|
|
202
|
+
return readState().guard;
|
|
203
|
+
}
|
|
204
|
+
/** Persist the guard policy. */
|
|
205
|
+
export function setGuard(policy) {
|
|
206
|
+
const state = readState();
|
|
207
|
+
state.guard = policy;
|
|
208
|
+
writeState(state);
|
|
209
|
+
}
|
|
194
210
|
// ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
|
|
195
211
|
/** Current rollback/circuit-breaker state for the `rebind` write path. */
|
|
196
212
|
export function readRebindKillSwitch() {
|
package/dist/providers.js
CHANGED
|
@@ -77,6 +77,7 @@ const claude = {
|
|
|
77
77
|
oneShotArgs: (prompt) => ["-p", prompt],
|
|
78
78
|
importFiles: [],
|
|
79
79
|
hasUsageReadout: true,
|
|
80
|
+
hasLiveSessionSignal: true,
|
|
80
81
|
};
|
|
81
82
|
const codex = {
|
|
82
83
|
id: "codex",
|
|
@@ -95,6 +96,7 @@ const codex = {
|
|
|
95
96
|
oneShotArgs: (prompt) => ["exec", prompt],
|
|
96
97
|
importFiles: ["auth.json"],
|
|
97
98
|
hasUsageReadout: true, // live via wham/usage (ChatGPT backend)
|
|
99
|
+
hasLiveSessionSignal: false, // rollouts are date-partitioned; no live-pid marker
|
|
98
100
|
};
|
|
99
101
|
const antigravity = {
|
|
100
102
|
id: "antigravity",
|
|
@@ -128,6 +130,7 @@ const antigravity = {
|
|
|
128
130
|
oneShotArgs: (prompt) => ["--print", prompt],
|
|
129
131
|
importFiles: [],
|
|
130
132
|
hasUsageReadout: false,
|
|
133
|
+
hasLiveSessionSignal: false,
|
|
131
134
|
};
|
|
132
135
|
const PROVIDERS = { claude, codex, antigravity };
|
|
133
136
|
export function provider(id) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-note normalisation — turn a GitHub release body into something a
|
|
3
|
+
* card or a terminal can show without it reading as broken.
|
|
4
|
+
*
|
|
5
|
+
* Mirrored in `gui/src/release-notes.ts` (same boundary reason as updates.ts:
|
|
6
|
+
* the GUI's tsconfig only includes `gui/src`, so pure helpers are duplicated
|
|
7
|
+
* rather than imported across it). `tests/release-notes.test.ts` byte-compares
|
|
8
|
+
* the two below their header comment, so a fix applied to one and not the other
|
|
9
|
+
* fails there — and it asserts against the real captured body
|
|
10
|
+
* (`tests/fixtures/agent-config-release-14.2.0.md`), which the GUI mirror's own
|
|
11
|
+
* suite cannot read: that project ships no `@types/node` on purpose.
|
|
12
|
+
*
|
|
13
|
+
* Why a normaliser and not `body.slice(0, 500)`: measured on agent-config
|
|
14
|
+
* 14.2.0, the first 500 characters are a heading, an HTML comment addressed to
|
|
15
|
+
* the release author, and five `_none_` placeholders. The shapes handled here
|
|
16
|
+
* are the ones that release template actually emits — headings, `*` items with
|
|
17
|
+
* a `**scope:**` prefix, `([sha](commit-url))` suffixes, `**bold**` / `_italic_`
|
|
18
|
+
* and horizontal rules. Everything is pure; no I/O, no markdown dependency.
|
|
19
|
+
*/
|
|
20
|
+
/** Drop HTML comments, including multi-line ones. This is what removes the
|
|
21
|
+
* release template's author-facing instruction line ("fill before merge …") —
|
|
22
|
+
* a comment is invisible on GitHub and must stay invisible here. */
|
|
23
|
+
export function stripHtmlComments(md) {
|
|
24
|
+
return String(md).replace(/<!--[\s\S]*?-->/g, "");
|
|
25
|
+
}
|
|
26
|
+
/** Inline cleanup applied to every line's text: commit-link suffixes go, real
|
|
27
|
+
* links collapse to their label, bold/italic markers are unwrapped, and runs
|
|
28
|
+
* of whitespace collapse. Inline code keeps its backticks on purpose — they
|
|
29
|
+
* mark a literal name, which is information, not noise. Pure. */
|
|
30
|
+
export function normalizeInline(s) {
|
|
31
|
+
return String(s)
|
|
32
|
+
// ` ([abc1234](https://…/commit/abc…))` — the generated changelog's sha
|
|
33
|
+
// suffix. Removed before generic link handling, which would otherwise
|
|
34
|
+
// leave a bare `(abc1234)` behind.
|
|
35
|
+
.replace(/\s*\(\[[0-9a-f]{7,40}\]\([^)\s]*\)\)/gi, "")
|
|
36
|
+
// `[label](url)` → `label`
|
|
37
|
+
.replace(/\[([^\]]*)\]\([^)\s]*\)/g, "$1")
|
|
38
|
+
// `**bold**` / `__bold__` → bold
|
|
39
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
40
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
41
|
+
// `_italic_` → italic (this is what turns `_none_` into `none`).
|
|
42
|
+
// Underscore-delimited only at word boundaries so `snake_case_names` and
|
|
43
|
+
// `file_name.ts` survive untouched.
|
|
44
|
+
.replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, "$1$2")
|
|
45
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
/** True for a markdown horizontal rule (`---`, `***`, `___`). */
|
|
49
|
+
function isHorizontalRule(line) {
|
|
50
|
+
return /^\s*([-*_])\s*(\1\s*){2,}$/.test(line);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse a release body into ordered blocks. Blank lines carry no block — the
|
|
54
|
+
* renderers space by `kind` instead, so a body with erratic blank runs comes
|
|
55
|
+
* out evenly spaced. An empty or comment-only body yields `[]`, which every
|
|
56
|
+
* caller must treat as "no notes" (render nothing — never a placeholder).
|
|
57
|
+
*/
|
|
58
|
+
export function releaseNoteBlocks(md) {
|
|
59
|
+
const blocks = [];
|
|
60
|
+
for (const raw of stripHtmlComments(md).split(/\r?\n/)) {
|
|
61
|
+
if (!raw.trim() || isHorizontalRule(raw))
|
|
62
|
+
continue;
|
|
63
|
+
const heading = /^\s*#{1,6}\s+(.*)$/.exec(raw);
|
|
64
|
+
if (heading) {
|
|
65
|
+
const text = normalizeInline(heading[1]);
|
|
66
|
+
if (text)
|
|
67
|
+
blocks.push({ kind: "heading", text });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const item = /^\s*[-*+]\s+(.*)$/.exec(raw);
|
|
71
|
+
if (item) {
|
|
72
|
+
const text = normalizeInline(item[1]);
|
|
73
|
+
if (text)
|
|
74
|
+
blocks.push({ kind: "item", text });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const text = normalizeInline(raw);
|
|
78
|
+
if (text)
|
|
79
|
+
blocks.push({ kind: "text", text });
|
|
80
|
+
}
|
|
81
|
+
return blocks;
|
|
82
|
+
}
|
|
83
|
+
/** A `_none_`-style empty value — the release template's way of saying "this
|
|
84
|
+
* section genuinely has nothing", which must not be offered as a highlight. */
|
|
85
|
+
function isEmptyValue(value) {
|
|
86
|
+
return /^(none|n\/a|-|—)$/i.test(value.trim());
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The one line worth putting in a notification: the first non-empty bullet
|
|
90
|
+
* under a "highlights"-ish heading, else the first bullet in the body. Returns
|
|
91
|
+
* null when the body carries no bullet with content — callers then say nothing
|
|
92
|
+
* rather than inventing a summary. Pure.
|
|
93
|
+
*/
|
|
94
|
+
export function firstHighlight(blocks) {
|
|
95
|
+
const start = blocks.findIndex((b) => b.kind === "heading" && /highlight/i.test(b.text));
|
|
96
|
+
const scan = (from, stopAtHeading) => {
|
|
97
|
+
for (let i = from; i < blocks.length; i++) {
|
|
98
|
+
const b = blocks[i];
|
|
99
|
+
if (b.kind === "heading" && stopAtHeading && i > from)
|
|
100
|
+
return null;
|
|
101
|
+
if (b.kind !== "item")
|
|
102
|
+
continue;
|
|
103
|
+
// `Behaviour changes: none` → value `none`; a bullet with no colon is
|
|
104
|
+
// its own value.
|
|
105
|
+
const colon = b.text.indexOf(":");
|
|
106
|
+
const value = colon >= 0 ? b.text.slice(colon + 1) : b.text;
|
|
107
|
+
if (!isEmptyValue(value))
|
|
108
|
+
return b.text;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
return start >= 0 ? (scan(start + 1, true) ?? scan(0, false)) : scan(0, false);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Render blocks as terminal lines: a blank line before each heading (never as
|
|
116
|
+
* the first line), items bulleted, text as-is. `omitted` is how many lines the
|
|
117
|
+
* cap dropped, so the caller can print an honest "… N more" plus the release
|
|
118
|
+
* URL instead of silently truncating. Pure.
|
|
119
|
+
*/
|
|
120
|
+
export function releaseNotesText(blocks, opts = {}) {
|
|
121
|
+
const bullet = opts.bullet ?? "•";
|
|
122
|
+
const indent = opts.indent ?? "";
|
|
123
|
+
const all = [];
|
|
124
|
+
for (const b of blocks) {
|
|
125
|
+
if (b.kind === "heading") {
|
|
126
|
+
if (all.length)
|
|
127
|
+
all.push("");
|
|
128
|
+
all.push(`${indent}${b.text}`);
|
|
129
|
+
}
|
|
130
|
+
else if (b.kind === "item") {
|
|
131
|
+
all.push(`${indent} ${bullet} ${b.text}`);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
all.push(`${indent} ${b.text}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const max = opts.maxLines;
|
|
138
|
+
if (max === undefined || all.length <= max)
|
|
139
|
+
return { lines: all, omitted: 0 };
|
|
140
|
+
return { lines: all.slice(0, max), omitted: all.length - max };
|
|
141
|
+
}
|
|
142
|
+
/** Clamp to `max` characters on a word boundary, appending an ellipsis when
|
|
143
|
+
* anything was cut. Used for the one-line notification summary, where a raw
|
|
144
|
+
* bullet can run to several hundred characters. Pure. */
|
|
145
|
+
export function clampText(s, max) {
|
|
146
|
+
const t = String(s).trim();
|
|
147
|
+
if (t.length <= max)
|
|
148
|
+
return t;
|
|
149
|
+
const cut = t.slice(0, max);
|
|
150
|
+
const space = cut.lastIndexOf(" ");
|
|
151
|
+
// Prefer the word boundary, but only when it still keeps at least half the
|
|
152
|
+
// budget — otherwise a long first word would collapse the summary to nothing.
|
|
153
|
+
return `${(space > max * 0.5 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-]+$/, "")}…`;
|
|
154
|
+
}
|
package/dist/sessions.js
CHANGED
|
@@ -174,6 +174,25 @@ export function markLive(configDir, rows, cwdOf = pidCwd) {
|
|
|
174
174
|
row.live = true;
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Auto-detect which Claude profile has a live session, for `rebind`'s
|
|
179
|
+
* no-`--profile` resolution. A profile is "running" when its config dir carries
|
|
180
|
+
* a live session pid (`<configDir>/sessions/<pid>.json` whose pid is alive).
|
|
181
|
+
*
|
|
182
|
+
* Returns `single` ONLY when exactly one profile is running — rebind writes a
|
|
183
|
+
* credential store, so the caller must never guess a target across ambiguity;
|
|
184
|
+
* `none` / `ambiguous` leave the caller on its explicit-`--profile` / active
|
|
185
|
+
* fallback. Pure (`livePidsOf` injected) so every branch is unit-tested without
|
|
186
|
+
* touching real processes.
|
|
187
|
+
*/
|
|
188
|
+
export function detectRunningClaudeProfile(profiles, configDirOf, livePidsOf = liveSessionPids) {
|
|
189
|
+
const running = profiles.filter((p) => livePidsOf(configDirOf(p)).length > 0);
|
|
190
|
+
if (running.length === 1)
|
|
191
|
+
return { kind: "single", profile: running[0] };
|
|
192
|
+
if (running.length > 1)
|
|
193
|
+
return { kind: "ambiguous", profiles: running };
|
|
194
|
+
return { kind: "none" };
|
|
195
|
+
}
|
|
177
196
|
/** Where a session id lives inside ONE profile config dir, or null. */
|
|
178
197
|
export function locateSession(configDir, sessionId) {
|
|
179
198
|
const projects = path.join(configDir, "projects");
|
package/dist/shellenv.js
CHANGED
|
@@ -13,8 +13,29 @@
|
|
|
13
13
|
* `asw` convenience: bare `asw` lists all providers' profiles; `asw <name>`
|
|
14
14
|
* switches the active Claude profile; `asw <provider> <name>` switches that
|
|
15
15
|
* provider's.
|
|
16
|
+
*
|
|
17
|
+
* SINGLE-LOAD GUARD. The wrapper is the one place every interactive start
|
|
18
|
+
* funnels through, so it is where the guard has to sit. `dir --guard` keeps the
|
|
19
|
+
* config path on stdout (unchanged) and writes the human message to stderr, so
|
|
20
|
+
* one invocation both feeds the wrapper and talks to the user — no second
|
|
21
|
+
* process launch per command.
|
|
22
|
+
*
|
|
23
|
+
* The wrapper blocks on EXACTLY ONE exit code (86, GUARD_REFUSED_EXIT) and on
|
|
24
|
+
* nothing else. That asymmetry is deliberate and load-bearing: a missing,
|
|
25
|
+
* older, or broken `agent-switch` exits 127 / 1 / 2 and the wrapper carries on
|
|
26
|
+
* to the real binary. This tool must never become the reason a user cannot
|
|
27
|
+
* reach their assistant.
|
|
28
|
+
*
|
|
29
|
+
* Each wrapper first checks that `agent-switch` exists at all and otherwise
|
|
30
|
+
* calls the real binary straight away. That check replaces the old
|
|
31
|
+
* `2>/dev/null`: the guard has to be able to SPEAK on stderr, so the blanket
|
|
32
|
+
* redirect had to go, and without the existence check an uninstalled
|
|
33
|
+
* `agent-switch` would print "command not found" on every single invocation.
|
|
34
|
+
* A side effect worth having: a genuinely broken agent-switch now surfaces its
|
|
35
|
+
* error instead of silently degrading the shell onto the default config dir.
|
|
16
36
|
*/
|
|
17
37
|
import { allProviders } from "./providers.js";
|
|
38
|
+
import { GUARD_REFUSED_EXIT } from "./single-load.js";
|
|
18
39
|
export const SHELLS = ["zsh", "bash", "fish", "powershell"];
|
|
19
40
|
/** Resolve the target shell: explicit request wins, else detect from the
|
|
20
41
|
* environment / platform. Windows defaults to PowerShell; POSIX reads $SHELL. */
|
|
@@ -40,8 +61,11 @@ export function detectShell(requested, env = process.env, platform = process.pla
|
|
|
40
61
|
}
|
|
41
62
|
function posixWrapper(binary, envVar, id) {
|
|
42
63
|
return `${binary}() {
|
|
43
|
-
local dir
|
|
44
|
-
|
|
64
|
+
local dir rc
|
|
65
|
+
command -v agent-switch >/dev/null 2>&1 || { command ${binary} "$@"; return; }
|
|
66
|
+
dir="$(command agent-switch dir --provider ${id} --guard)"
|
|
67
|
+
rc=$?
|
|
68
|
+
if [ $rc -eq ${GUARD_REFUSED_EXIT} ]; then return $rc; fi
|
|
45
69
|
if [ -n "$dir" ]; then
|
|
46
70
|
${envVar}="$dir" command ${binary} "$@"
|
|
47
71
|
else
|
|
@@ -51,7 +75,15 @@ function posixWrapper(binary, envVar, id) {
|
|
|
51
75
|
}
|
|
52
76
|
function fishWrapper(binary, envVar, id) {
|
|
53
77
|
return `function ${binary}
|
|
54
|
-
|
|
78
|
+
if not type -q agent-switch
|
|
79
|
+
command ${binary} $argv
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
set -l dir (command agent-switch dir --provider ${id} --guard)
|
|
83
|
+
set -l rc $status
|
|
84
|
+
if test $rc -eq ${GUARD_REFUSED_EXIT}
|
|
85
|
+
return $rc
|
|
86
|
+
end
|
|
55
87
|
if test -n "$dir"
|
|
56
88
|
${envVar}=$dir command ${binary} $argv
|
|
57
89
|
else
|
|
@@ -63,7 +95,9 @@ function powershellWrapper(binary, envVar, id) {
|
|
|
63
95
|
return `function ${binary} {
|
|
64
96
|
$exe = Get-Command ${binary} -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
65
97
|
if (-not $exe) { Write-Error '${binary} not found on PATH.'; return }
|
|
66
|
-
|
|
98
|
+
if (-not (Get-Command agent-switch -ErrorAction SilentlyContinue)) { & $exe.Source @args; return }
|
|
99
|
+
$dir = (& agent-switch dir --provider ${id} --guard)
|
|
100
|
+
if ($LASTEXITCODE -eq ${GUARD_REFUSED_EXIT}) { return }
|
|
67
101
|
if ($dir) {
|
|
68
102
|
$prev = Get-Item -Path Env:\\${envVar} -ErrorAction SilentlyContinue
|
|
69
103
|
$env:${envVar} = $dir
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-load guard: one profile, one live session.
|
|
3
|
+
*
|
|
4
|
+
* The problem it solves: `agent-switch use` writes a GLOBAL active pointer and
|
|
5
|
+
* the shell wrapper re-resolves it on every invocation, so nothing stops two
|
|
6
|
+
* terminals from starting `claude` on the same profile. Two live sessions on
|
|
7
|
+
* one account share its rate-limit windows and both refresh one rotating OAuth
|
|
8
|
+
* store (the rotation `src/locks.ts` documents) — and, worst of the three, the
|
|
9
|
+
* user believes the second terminal is on a different account because they
|
|
10
|
+
* typed `asw <other>` there.
|
|
11
|
+
*
|
|
12
|
+
* Ground truth is Claude Code's own bookkeeping, never ours: a live session
|
|
13
|
+
* writes `<config>/sessions/<pid>.json`, and `liveSessionPids` filters dead
|
|
14
|
+
* pids on every read. That makes the signal self-healing — a crashed session
|
|
15
|
+
* leaves a stale file that is simply never counted, so this module needs no
|
|
16
|
+
* cleanup job and holds no lease of its own. (An agent-switch-owned lease would
|
|
17
|
+
* need a holder process; the shell wrapper execs `claude` and exits, so every
|
|
18
|
+
* crash would leak one.)
|
|
19
|
+
*
|
|
20
|
+
* Everything here is pure — processes, environment, and clock are injected — so
|
|
21
|
+
* every branch is unit-tested without spawning anything.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { liveSessionPids } from "./api.js";
|
|
26
|
+
import { pidCwd } from "./sessions.js";
|
|
27
|
+
export const GUARD_POLICIES = ["block", "warn", "off"];
|
|
28
|
+
export const DEFAULT_GUARD_POLICY = "block";
|
|
29
|
+
export function isGuardPolicy(v) {
|
|
30
|
+
return typeof v === "string" && GUARD_POLICIES.includes(v);
|
|
31
|
+
}
|
|
32
|
+
/** Env var that waives the guard for exactly one invocation. An env var, not a
|
|
33
|
+
* flag: the shell wrapper passes every unknown flag THROUGH to the provider
|
|
34
|
+
* binary, so a `--force` there would reach `claude`, not us. */
|
|
35
|
+
export const OVERRIDE_ENV = "AGENT_SWITCH_ALLOW_DUPLICATE";
|
|
36
|
+
/** Exit code the wrapper keys on. Distinct from every generic failure (1, 2,
|
|
37
|
+
* 127 …) so the wrapper blocks on a REFUSAL and never on a missing or older
|
|
38
|
+
* `agent-switch` — fail-open is deliberate: this tool must never be the reason
|
|
39
|
+
* a user cannot start their assistant. */
|
|
40
|
+
export const GUARD_REFUSED_EXIT = 86;
|
|
41
|
+
function pidFileMtime(configDir, pid) {
|
|
42
|
+
try {
|
|
43
|
+
return fs.statSync(path.join(configDir, "sessions", `${pid}.json`)).mtimeMs;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Who currently holds `configDir`, and whether we are a child of one of them.
|
|
51
|
+
*
|
|
52
|
+
* `envVar` is the provider's isolation variable (`CLAUDE_CONFIG_DIR`, …). The
|
|
53
|
+
* nested comparison is on the RAW string, deliberately unresolved: that same
|
|
54
|
+
* unresolved string is what Claude Code hashes into its keychain service name
|
|
55
|
+
* (`profiles.ts` § configDir), so resolving it here would compare a different
|
|
56
|
+
* identity than the one the rest of the tool uses.
|
|
57
|
+
*/
|
|
58
|
+
export function inspectLoad(configDir, envVar, deps = {}) {
|
|
59
|
+
const livePidsOf = deps.livePidsOf ?? liveSessionPids;
|
|
60
|
+
const cwdOf = deps.cwdOf ?? pidCwd;
|
|
61
|
+
const startedMsOf = deps.startedMsOf ?? pidFileMtime;
|
|
62
|
+
const env = deps.env ?? process.env;
|
|
63
|
+
const nested = env[envVar] === configDir;
|
|
64
|
+
const pids = livePidsOf(configDir);
|
|
65
|
+
// A nested start is allowed unconditionally, so its message is never
|
|
66
|
+
// rendered — and resolving a cwd costs an `lsof` per pid on darwin. Skip the
|
|
67
|
+
// lookup rather than pay it on a path that cannot use the answer.
|
|
68
|
+
const sessions = pids.map((pid) => ({
|
|
69
|
+
pid,
|
|
70
|
+
cwd: nested ? null : cwdOf(pid),
|
|
71
|
+
startedMs: startedMsOf(configDir, pid),
|
|
72
|
+
}));
|
|
73
|
+
return { sessions, loaded: sessions.length > 0, nested };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pure policy resolution. Order matters and each precedence is load-bearing:
|
|
77
|
+
* an undetectable provider can never refuse (no evidence), a nested start is
|
|
78
|
+
* a deliberate child rather than a duplicate, and an explicit override outranks
|
|
79
|
+
* the policy because the user just stated an intent this turn.
|
|
80
|
+
*/
|
|
81
|
+
export function guardVerdict({ policy, inspection, overridden = false, detectable = true }) {
|
|
82
|
+
if (!detectable)
|
|
83
|
+
return "allow";
|
|
84
|
+
if (policy === "off")
|
|
85
|
+
return "allow";
|
|
86
|
+
if (!inspection.loaded)
|
|
87
|
+
return "allow";
|
|
88
|
+
if (inspection.nested)
|
|
89
|
+
return "allow";
|
|
90
|
+
if (overridden)
|
|
91
|
+
return "warn"; // proceed, but still say what is happening
|
|
92
|
+
return policy === "warn" ? "warn" : "refuse";
|
|
93
|
+
}
|
|
94
|
+
/** True when the one-shot override env var is set to a meaningful value. */
|
|
95
|
+
export function overrideFromEnv(env = process.env) {
|
|
96
|
+
const v = env[OVERRIDE_ENV];
|
|
97
|
+
return typeof v === "string" && v !== "" && v !== "0" && v.toLowerCase() !== "false";
|
|
98
|
+
}
|
|
99
|
+
/** Relative age like "3m" / "2h" — mirrors the sessions listing's `ageOf`. */
|
|
100
|
+
function ageOf(startedMs, now) {
|
|
101
|
+
if (startedMs === null)
|
|
102
|
+
return null;
|
|
103
|
+
const s = Math.max(0, Math.round((now - startedMs) / 1000));
|
|
104
|
+
if (s < 60)
|
|
105
|
+
return `${s}s`;
|
|
106
|
+
if (s < 3600)
|
|
107
|
+
return `${Math.round(s / 60)}m`;
|
|
108
|
+
if (s < 86400)
|
|
109
|
+
return `${Math.round(s / 3600)}h`;
|
|
110
|
+
return `${Math.round(s / 86400)}d`;
|
|
111
|
+
}
|
|
112
|
+
/** Render actions as a table whose column width comes from the widest action,
|
|
113
|
+
* so a long override line can never collide with its own explanation. */
|
|
114
|
+
function actionTable(rows) {
|
|
115
|
+
const width = Math.max(...rows.map(([action]) => action.length));
|
|
116
|
+
return rows.map(([action, explanation]) => ` ${action.padEnd(width + 2)}${explanation}`);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The one place the guard speaks. Every enforcement point renders through this
|
|
120
|
+
* so the CLI, the wrapper path, and `use` never drift into three dialects.
|
|
121
|
+
*
|
|
122
|
+
* A refusal that only says "no" hands the problem straight back, so the message
|
|
123
|
+
* names WHO holds the profile (pid, age, directory), WHY that matters, and the
|
|
124
|
+
* concrete ways forward — including the names of the profiles that are actually
|
|
125
|
+
* free, and how to switch the guard off.
|
|
126
|
+
*/
|
|
127
|
+
export function renderGuardMessage(input) {
|
|
128
|
+
const { profile, binary, verdict, inspection, offerForceFlag = false, alternatives = [], context = "start", now = Date.now(), } = input;
|
|
129
|
+
const n = inspection.sessions.length;
|
|
130
|
+
const held = `profile "${profile}" is already loaded by ${n === 1 ? "a running" : `${n} running`} ${binary} session${n === 1 ? "" : "s"}`;
|
|
131
|
+
const headline = context === "use"
|
|
132
|
+
? verdict === "refuse"
|
|
133
|
+
? `${held} — the next \`${binary}\` started here will be refused.`
|
|
134
|
+
: `${held} — a second session will be allowed (guard: warn).`
|
|
135
|
+
: verdict === "refuse"
|
|
136
|
+
? `${held}.`
|
|
137
|
+
: `${held} — continuing anyway.`;
|
|
138
|
+
const lines = [headline, ""];
|
|
139
|
+
for (const sess of inspection.sessions) {
|
|
140
|
+
const age = ageOf(sess.startedMs, now);
|
|
141
|
+
const parts = [` pid ${sess.pid}`];
|
|
142
|
+
if (age)
|
|
143
|
+
parts.push(`started ${age} ago`);
|
|
144
|
+
if (sess.cwd)
|
|
145
|
+
parts.push(sess.cwd);
|
|
146
|
+
lines.push(parts.join(" "));
|
|
147
|
+
}
|
|
148
|
+
lines.push("", "Two sessions on one account share its rate limits, and both refresh the", "same rotating OAuth token.");
|
|
149
|
+
if (verdict === "refuse") {
|
|
150
|
+
const rows = [];
|
|
151
|
+
rows.push([
|
|
152
|
+
`agent-switch use ${alternatives.length > 0 ? alternatives[0] : "<other-profile>"}`,
|
|
153
|
+
alternatives.length > 1
|
|
154
|
+
? `switch to a free account — also free: ${alternatives.slice(1).join(", ")}`
|
|
155
|
+
: alternatives.length === 1
|
|
156
|
+
? "switch to the account that is free"
|
|
157
|
+
: "point new sessions at another account",
|
|
158
|
+
]);
|
|
159
|
+
// Both overrides do the same thing, so only one is offered: `--force` where
|
|
160
|
+
// the caller can accept a flag, the env var everywhere else. A shell wrapper
|
|
161
|
+
// passes unknown flags THROUGH to the provider binary, which is why the env
|
|
162
|
+
// var exists at all — offering both would just be two names for one action.
|
|
163
|
+
rows.push(offerForceFlag
|
|
164
|
+
? ["--force", "start it anyway, this once"]
|
|
165
|
+
: [`${OVERRIDE_ENV}=1 ${binary}`, "start it anyway, this once"]);
|
|
166
|
+
rows.push(["agent-switch guard warn", "warn instead of refusing, from now on"]);
|
|
167
|
+
rows.push(["agent-switch guard off", "turn the guard off entirely"]);
|
|
168
|
+
lines.push("", "Ways forward:", ...actionTable(rows));
|
|
169
|
+
}
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
package/dist/tooling.js
CHANGED
|
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
|
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { readBinaryPath } from "./profiles.js";
|
|
21
21
|
import { npmSearchPath } from "./updates.js";
|
|
22
|
+
import { releaseNoteBlocks, releaseNotesText } from "./release-notes.js";
|
|
22
23
|
export const TOOL_IDS = ["agent-config", "rtk", "claude", "codex", "agy"];
|
|
23
24
|
/** Which provider a probed binary belongs to (for `providers link` hints and
|
|
24
25
|
* the user-linked path lookup). */
|
|
@@ -322,3 +323,75 @@ export function doctorToolingLine(t) {
|
|
|
322
323
|
}
|
|
323
324
|
return `\`${t.id}\`: ${t.hint}`;
|
|
324
325
|
}
|
|
326
|
+
// ---------- release notes (`tooling notes <tool>`) --------------------------------
|
|
327
|
+
/**
|
|
328
|
+
* Repos whose GitHub Releases are a tool's release-note source. Only tools with
|
|
329
|
+
* a source this repo has actually verified appear here — inventing a plausible
|
|
330
|
+
* repo for the rest would be worse than the refusal `toolNotesRefusal` prints.
|
|
331
|
+
* Same two sources the GUI uses (agent-config's release check, rtk's update
|
|
332
|
+
* check), so the CLI and the GUI can never describe a release differently.
|
|
333
|
+
*/
|
|
334
|
+
export const TOOL_RELEASE_REPOS = {
|
|
335
|
+
"agent-config": "event4u-app/agent-config",
|
|
336
|
+
rtk: "rtk-ai/rtk",
|
|
337
|
+
};
|
|
338
|
+
/** The release-notes source for a tool, or null when it has none. */
|
|
339
|
+
export function toolReleaseRepo(id) {
|
|
340
|
+
return TOOL_RELEASE_REPOS[id] ?? null;
|
|
341
|
+
}
|
|
342
|
+
/** Why a tool has no readable release notes here — named, never guessed. */
|
|
343
|
+
export function toolNotesRefusal(id) {
|
|
344
|
+
if (id === "claude" || id === "codex") {
|
|
345
|
+
return (`\`${id}\` is distributed on npm and this repo has no verified GitHub release source for it, ` +
|
|
346
|
+
`so there are no notes to show rather than a guessed one.\n` +
|
|
347
|
+
` Its installed version is in \`agent-switch tooling\`; upgrade with \`agent-switch tooling upgrade ${id}\`.`);
|
|
348
|
+
}
|
|
349
|
+
return (`\`${id}\` has no known release source — the Antigravity CLI ships with the Antigravity app and publishes no releases here.\n` +
|
|
350
|
+
` Its installed version is in \`agent-switch tooling\`.`);
|
|
351
|
+
}
|
|
352
|
+
/** The header line: what is installed, what is published, and whether anything
|
|
353
|
+
* is waiting. Pure — every branch is unit-tested. */
|
|
354
|
+
export function toolNotesHeader(view) {
|
|
355
|
+
const { id, installed, latest } = view;
|
|
356
|
+
if (!installed) {
|
|
357
|
+
return `${id} — not installed · ${latest ? `latest ${latest}` : "latest unknown"}`;
|
|
358
|
+
}
|
|
359
|
+
if (!latest) {
|
|
360
|
+
return `${id} — installed ${installed} · latest unknown (offline or rate-limited)`;
|
|
361
|
+
}
|
|
362
|
+
const pending = view.releases.filter((r) => r.tag !== installed).length;
|
|
363
|
+
if (installed === latest || pending === 0)
|
|
364
|
+
return `${id} — installed ${installed} · up to date`;
|
|
365
|
+
return `${id} — installed ${installed} · latest ${latest} · ${pending} update${pending === 1 ? "" : "s"} to read`;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The full `tooling notes` rendering: header, then each release as a dated
|
|
369
|
+
* block of normalised notes plus its URL. A release whose body normalises to
|
|
370
|
+
* nothing is listed with its URL and an explicit "no notes in this release" —
|
|
371
|
+
* unlike the GUI panel, a terminal command the user typed must answer rather
|
|
372
|
+
* than print nothing. `maxLinesPerRelease` caps a long body and says how much
|
|
373
|
+
* was left out. Pure.
|
|
374
|
+
*/
|
|
375
|
+
export function formatToolNotes(view, maxLinesPerRelease = 40) {
|
|
376
|
+
const out = [toolNotesHeader(view)];
|
|
377
|
+
if (view.releases.length === 0) {
|
|
378
|
+
out.push("", "No release notes available.");
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
for (const r of view.releases) {
|
|
382
|
+
const date = /^\d{4}-\d{2}-\d{2}/.test(r.publishedAt) ? r.publishedAt.slice(0, 10) : null;
|
|
383
|
+
out.push("", `${r.name || r.tag}${date ? ` — ${date}` : ""}`);
|
|
384
|
+
const { lines, omitted } = releaseNotesText(releaseNoteBlocks(r.notes), {
|
|
385
|
+
maxLines: maxLinesPerRelease,
|
|
386
|
+
indent: " ",
|
|
387
|
+
});
|
|
388
|
+
if (lines.length === 0)
|
|
389
|
+
out.push(" (no notes in this release)");
|
|
390
|
+
else
|
|
391
|
+
out.push(...lines);
|
|
392
|
+
if (omitted > 0)
|
|
393
|
+
out.push(` … ${omitted} more line${omitted === 1 ? "" : "s"}`);
|
|
394
|
+
out.push(` ${r.url}`);
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
package/dist/updates.js
CHANGED
|
@@ -47,8 +47,10 @@ export function isNewer(latest, current) {
|
|
|
47
47
|
return compareVersions(latest, current) > 0;
|
|
48
48
|
}
|
|
49
49
|
/** Reshape a raw GitHub `releases/latest` payload into {@link ReleaseInfo}, or
|
|
50
|
-
* null for a draft/prerelease or a payload without a tag. Pure — no network.
|
|
51
|
-
|
|
50
|
+
* null for a draft/prerelease or a payload without a tag. Pure — no network.
|
|
51
|
+
* `repo` only supplies the fallback URL for a payload missing `html_url`; it
|
|
52
|
+
* defaults to this app's own repo so existing callers are unaffected. */
|
|
53
|
+
export function parseRelease(raw, repo = UPDATE_REPO) {
|
|
52
54
|
if (!raw || typeof raw !== "object")
|
|
53
55
|
return null;
|
|
54
56
|
const r = raw;
|
|
@@ -60,11 +62,43 @@ export function parseRelease(raw) {
|
|
|
60
62
|
return {
|
|
61
63
|
tag,
|
|
62
64
|
name: typeof r.name === "string" && r.name ? r.name : tag,
|
|
63
|
-
url: typeof r.html_url === "string" ? r.html_url : `https://github.com/${
|
|
65
|
+
url: typeof r.html_url === "string" ? r.html_url : `https://github.com/${repo}/releases`,
|
|
64
66
|
notes: typeof r.body === "string" ? r.body : "",
|
|
65
67
|
publishedAt: typeof r.published_at === "string" ? r.published_at : "",
|
|
66
68
|
};
|
|
67
69
|
}
|
|
70
|
+
/** Reshape a raw GitHub `/releases` array (drafts and prereleases dropped by
|
|
71
|
+
* {@link parseRelease}'s existing rule) and order it newest version first, so
|
|
72
|
+
* the ordering is the version math rather than GitHub's creation order. Pure. */
|
|
73
|
+
export function parseReleases(raw, repo = UPDATE_REPO) {
|
|
74
|
+
if (!Array.isArray(raw))
|
|
75
|
+
return [];
|
|
76
|
+
return raw
|
|
77
|
+
.map((r) => parseRelease(r, repo))
|
|
78
|
+
.filter((r) => r !== null)
|
|
79
|
+
.sort((a, b) => compareVersions(b.tag, a.tag));
|
|
80
|
+
}
|
|
81
|
+
/** The releases strictly newer than `current`, newest first — i.e. everything
|
|
82
|
+
* the user has not seen yet, which is what "what changed" actually means for
|
|
83
|
+
* someone several versions behind. Pure. */
|
|
84
|
+
export function releasesNewerThan(releases, current) {
|
|
85
|
+
return releases.filter((r) => isNewer(r.tag, current)).sort((a, b) => compareVersions(b.tag, a.tag));
|
|
86
|
+
}
|
|
87
|
+
/** Recent releases for a repo, newest first. `[]` when the repo has none (404)
|
|
88
|
+
* — a normal empty state. Throws on any other network/HTTP failure so the
|
|
89
|
+
* caller can fall back to {@link fetchLatestRelease} rather than silently
|
|
90
|
+
* reporting "nothing new". One request; the unauthenticated endpoint is
|
|
91
|
+
* rate-limited (60/h per IP), ample for an hourly check. */
|
|
92
|
+
export async function fetchReleases(repo = UPDATE_REPO, perPage = 20) {
|
|
93
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=${perPage}`, {
|
|
94
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": `${PACKAGE_NAME} cli-updater` },
|
|
95
|
+
});
|
|
96
|
+
if (res.status === 404)
|
|
97
|
+
return [];
|
|
98
|
+
if (!res.ok)
|
|
99
|
+
throw new Error(`GitHub API returned ${res.status}`);
|
|
100
|
+
return parseReleases(await res.json(), repo);
|
|
101
|
+
}
|
|
68
102
|
/** The running CLI's version, from the package's own package.json (one level up
|
|
69
103
|
* from the compiled dist/). "0.0.0" if unreadable so callers never crash. */
|
|
70
104
|
export function currentVersion() {
|
package/package.json
CHANGED