@event4u/agent-switch 1.7.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 +36 -1
- package/dist/api.js +5 -4
- package/dist/codex-reset.js +2 -2
- package/dist/config-transfer.js +194 -0
- package/dist/credentials.js +6 -4
- package/dist/daemon.js +68 -34
- package/dist/doctor.js +23 -2
- package/dist/gui-launch.js +45 -3
- package/dist/history.js +23 -0
- package/dist/hooks.js +5 -2
- package/dist/index.js +475 -72
- package/dist/keychain.js +14 -0
- package/dist/notifications.js +7 -2
- package/dist/profiles.js +42 -14
- package/dist/providers.js +3 -0
- package/dist/rebind-dialog.js +65 -0
- package/dist/rebind.js +715 -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/token-freshen.js +154 -0
- package/dist/tooling.js +73 -0
- package/dist/updates.js +37 -3
- package/dist/usage.js +8 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -13,26 +13,33 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
+
import * as readline from "node:readline";
|
|
16
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
|
-
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,
|
|
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";
|
|
18
19
|
import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
|
|
20
|
+
import { rebind, restoreRebind } from "./rebind.js";
|
|
21
|
+
import { buildRebindDialog, resolveRebindChoice, selectableRows } from "./rebind-dialog.js";
|
|
19
22
|
import { parseArgs, parseRun, resolveProviderValue } from "./args.js";
|
|
20
23
|
import { extractBrief, writeBrief, sweepBriefs, seedPrompt } from "./handoff.js";
|
|
21
24
|
import { credentialStore } from "./credentials.js";
|
|
22
25
|
import { withProperLock } from "./locks.js";
|
|
23
26
|
import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./share.js";
|
|
27
|
+
import { assertNotFull, exportConfig, importConfig } from "./config-transfer.js";
|
|
24
28
|
import { detectShell, shellenvScript } from "./shellenv.js";
|
|
25
29
|
import { runDoctor } from "./doctor.js";
|
|
26
|
-
import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.js";
|
|
27
|
-
import {
|
|
30
|
+
import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
|
|
31
|
+
import { releaseNoteBlocks } from "./release-notes.js";
|
|
32
|
+
import { readHistory, recordHistorySample } from "./history.js";
|
|
28
33
|
import { launchGui } from "./gui-launch.js";
|
|
29
|
-
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";
|
|
30
36
|
import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
|
|
31
|
-
import {
|
|
37
|
+
import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
|
|
38
|
+
import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
|
|
32
39
|
import { formatSnapshot, parseUsage } from "./usage.js";
|
|
33
40
|
import { readCodexUsage } from "./codex-usage.js";
|
|
34
41
|
import { redeemResetCredit } from "./codex-reset.js";
|
|
35
|
-
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";
|
|
36
43
|
import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
|
|
37
44
|
import { cmdService, serviceUninstall } from "./service.js";
|
|
38
45
|
import { readContext, turnInFlight } from "./telemetry.js";
|
|
@@ -47,6 +54,46 @@ import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmux
|
|
|
47
54
|
const credentials = credentialStore();
|
|
48
55
|
const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
|
|
49
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
|
+
}
|
|
50
97
|
// ---------- launcher ---------------------------------------------------------
|
|
51
98
|
function launch(p, name, args) {
|
|
52
99
|
const home = configDir(p.id, name);
|
|
@@ -160,6 +207,14 @@ function cmdUse(providerId, name) {
|
|
|
160
207
|
const id = identity(providerId, n);
|
|
161
208
|
console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
|
|
162
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
|
+
}
|
|
163
218
|
}
|
|
164
219
|
function cmdDeactivate(providerId) {
|
|
165
220
|
const active = activeFor(providerId);
|
|
@@ -174,9 +229,18 @@ function cmdDeactivate(providerId) {
|
|
|
174
229
|
function cmdRun(providerId, name, args) {
|
|
175
230
|
const n = requireProfile(providerId, name, "run");
|
|
176
231
|
const p = provider(providerId);
|
|
177
|
-
// `--tmux`
|
|
232
|
+
// `--tmux` and `--force` are agent-switch flags, not passthrough flags — strip
|
|
233
|
+
// them before the rest reaches the provider binary.
|
|
178
234
|
const wantTmux = args.includes("--tmux");
|
|
179
|
-
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);
|
|
180
244
|
if (wantTmux) {
|
|
181
245
|
if (!tmuxAvailable()) {
|
|
182
246
|
console.log("(--tmux unavailable — tmux not found or Windows; running normally.)");
|
|
@@ -199,8 +263,12 @@ function printProfileLine(providerId, name, showLive) {
|
|
|
199
263
|
let live = "";
|
|
200
264
|
if (showLive && providerId === "claude") {
|
|
201
265
|
const pids = liveSessionPids(configDir("claude", name));
|
|
202
|
-
|
|
203
|
-
|
|
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]";
|
|
204
272
|
}
|
|
205
273
|
console.log(`${mark} ${name.padEnd(16)} ${id}${live}`);
|
|
206
274
|
}
|
|
@@ -272,8 +340,7 @@ async function claudeSnapshot(name) {
|
|
|
272
340
|
if (cached)
|
|
273
341
|
return cached;
|
|
274
342
|
}
|
|
275
|
-
const
|
|
276
|
-
const token = creds ? accessTokenOf(creds) : null;
|
|
343
|
+
const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
|
|
277
344
|
if (!token)
|
|
278
345
|
return null;
|
|
279
346
|
const raw = await fetchUsage(token);
|
|
@@ -295,6 +362,12 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
295
362
|
: pid === "codex"
|
|
296
363
|
? await readCodexUsage(configDir("codex", active))
|
|
297
364
|
: null;
|
|
365
|
+
// Feed the 30-day usage history from the GUI's own fetches: the GUI polls
|
|
366
|
+
// every displayed profile on each refresh, so recording the fresh snapshot
|
|
367
|
+
// here (throttled to ~hourly) fills the chart for all profiles without
|
|
368
|
+
// depending on the background daemon or an "active" profile being set.
|
|
369
|
+
if (usage)
|
|
370
|
+
recordHistorySample(path.join(profileDir(pid, active), "usage-history.json"), usage);
|
|
298
371
|
const wc = worstLiveContext(pid, active);
|
|
299
372
|
const context = wc
|
|
300
373
|
? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
|
|
@@ -328,8 +401,7 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
328
401
|
console.log(" (no usage readout for this provider — shows own usage only where available)");
|
|
329
402
|
continue;
|
|
330
403
|
}
|
|
331
|
-
const
|
|
332
|
-
const token = creds ? accessTokenOf(creds) : null;
|
|
404
|
+
const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
|
|
333
405
|
if (!token) {
|
|
334
406
|
console.log(" (credential not readable — profile may not have run yet)");
|
|
335
407
|
continue;
|
|
@@ -353,17 +425,37 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
353
425
|
}
|
|
354
426
|
}
|
|
355
427
|
}
|
|
356
|
-
|
|
357
|
-
|
|
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) {
|
|
358
440
|
const mapped = resolveMapping(process.cwd(), providerId);
|
|
359
|
-
|
|
360
|
-
|
|
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)
|
|
361
448
|
return;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
//
|
|
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);
|
|
367
459
|
}
|
|
368
460
|
function cmdMap(providerId, name, dir) {
|
|
369
461
|
const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile> [dir]`);
|
|
@@ -451,6 +543,55 @@ function cmdShare(mode, flags = []) {
|
|
|
451
543
|
die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
|
|
452
544
|
}
|
|
453
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* `agent-switch config export|import <profile> [--full]` — move a Claude
|
|
548
|
+
* profile's SHARED config (settings.json, keybindings.json, CLAUDE.md, skills/,
|
|
549
|
+
* commands/, agents/) between machines as a JSON bundle. Config-ONLY — it never
|
|
550
|
+
* touches credentials. Claude-scoped (config lives under the Claude layout).
|
|
551
|
+
*
|
|
552
|
+
* export → the bundle goes to STDOUT (pipe it: `| gpg -c > backup.gpg`), all
|
|
553
|
+
* status on STDERR so stdout stays a clean bundle.
|
|
554
|
+
* import → reads a bundle from STDIN into the profile's config dir.
|
|
555
|
+
* --full → REFUSED (would bundle live OAuth tokens; account-takeover vector).
|
|
556
|
+
*/
|
|
557
|
+
function cmdConfig(sub, name, flags = {}) {
|
|
558
|
+
try {
|
|
559
|
+
assertNotFull(!!flags.full);
|
|
560
|
+
}
|
|
561
|
+
catch (e) {
|
|
562
|
+
die(e.message);
|
|
563
|
+
}
|
|
564
|
+
if (sub === "export") {
|
|
565
|
+
const n = requireProfile("claude", name, "config export");
|
|
566
|
+
const bundle = exportConfig(configDir("claude", n));
|
|
567
|
+
// Status on stderr — stdout is the bundle (so `... | gpg -c > backup.gpg` works).
|
|
568
|
+
console.error(`Exported config-only bundle for claude/${n} (SHARED_ITEMS allowlist; no credentials).`);
|
|
569
|
+
process.stdout.write(bundle + "\n");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (sub === "import") {
|
|
573
|
+
const n = requireProfile("claude", name, "config import");
|
|
574
|
+
let input;
|
|
575
|
+
try {
|
|
576
|
+
input = fs.readFileSync(0, "utf8"); // stdin
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
die("config import reads a bundle from stdin — pipe one in (e.g. `gpg -d backup.gpg | agent-switch config import <profile>`).");
|
|
580
|
+
}
|
|
581
|
+
let written;
|
|
582
|
+
try {
|
|
583
|
+
written = importConfig(configDir("claude", n), input);
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
die(e.message);
|
|
587
|
+
}
|
|
588
|
+
console.error(`Imported ${written.length} config file(s) into claude/${n} (config-only; no credentials):`);
|
|
589
|
+
for (const w of written)
|
|
590
|
+
console.error(` ${w}`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
die("usage: agent-switch config export|import <profile> (config-only; pipe export | gpg -c > backup.gpg)");
|
|
594
|
+
}
|
|
454
595
|
// ---------- session inventory + takeover (Claude; Codex lands per G0.3) ------
|
|
455
596
|
/** Relative age like "3m" / "2h" / "5d" for the sessions table. */
|
|
456
597
|
function ageOf(mtimeMs) {
|
|
@@ -660,6 +801,16 @@ function cmdHookEvent() {
|
|
|
660
801
|
at: new Date().toISOString(),
|
|
661
802
|
};
|
|
662
803
|
appendEvent(eventFile(ROOT, who.provider, who.profile), rec);
|
|
804
|
+
// Session awareness: surface "Claude needs you / finished" as a notification
|
|
805
|
+
// the GUI routes to the pet. dedupe=false — each turn is a distinct moment,
|
|
806
|
+
// so a solo session's repeated turns are never collapsed. Best-effort; a hook
|
|
807
|
+
// must never throw.
|
|
808
|
+
if (rec.event === "Notification") {
|
|
809
|
+
appendNotification({ kind: "warning", title: "Claude is waiting", message: `${who.provider}/${who.profile} needs your input.` }, undefined, undefined, false);
|
|
810
|
+
}
|
|
811
|
+
else if (rec.event === "Stop") {
|
|
812
|
+
appendNotification({ kind: "info", title: "Claude finished", message: `${who.provider}/${who.profile} finished its turn.` }, undefined, undefined, false);
|
|
813
|
+
}
|
|
663
814
|
}
|
|
664
815
|
catch {
|
|
665
816
|
// never throw from a hook
|
|
@@ -1212,7 +1363,8 @@ function cmdLabel(providerId, name, label) {
|
|
|
1212
1363
|
setLabel(providerId, name, label);
|
|
1213
1364
|
console.log(`Labeled ${providerId}/${name} as ${label}.`);
|
|
1214
1365
|
}
|
|
1215
|
-
/** Enable/disable opt-in
|
|
1366
|
+
/** Enable/disable opt-in near-limit notifications (notify + suggest, never an
|
|
1367
|
+
* automatic switch), or show the current setting. */
|
|
1216
1368
|
/** Rename a profile (move its config dir + carry active/label/mappings). */
|
|
1217
1369
|
function cmdRename(providerId, from, to) {
|
|
1218
1370
|
const n = requireProfile(providerId, from, "rename");
|
|
@@ -1256,23 +1408,7 @@ async function cmdReset(providerId, name) {
|
|
|
1256
1408
|
die(`reset failed: ${r.reason ?? "unknown"}`);
|
|
1257
1409
|
}
|
|
1258
1410
|
}
|
|
1259
|
-
function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}
|
|
1260
|
-
// `autoswitch strategy [reset-first|rotation-first]` — global switch behaviour.
|
|
1261
|
-
if (mode === "strategy") {
|
|
1262
|
-
if (value === "reset-first" || value === "rotation-first") {
|
|
1263
|
-
setSwitchStrategy(value);
|
|
1264
|
-
}
|
|
1265
|
-
else if (value !== undefined) {
|
|
1266
|
-
die("usage: agent-switch autoswitch strategy [reset-first|rotation-first]");
|
|
1267
|
-
}
|
|
1268
|
-
if (flags.json) {
|
|
1269
|
-
console.log(JSON.stringify({ strategy: readSwitchStrategy() }));
|
|
1270
|
-
}
|
|
1271
|
-
else {
|
|
1272
|
-
console.log(`Auto-switch strategy: ${readSwitchStrategy()}.`);
|
|
1273
|
-
}
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1411
|
+
function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
|
|
1276
1412
|
const thresholdFlag = flags.threshold;
|
|
1277
1413
|
const threshold = typeof thresholdFlag === "string" ? Number(thresholdFlag) : undefined;
|
|
1278
1414
|
if (threshold !== undefined && (!Number.isFinite(threshold) || threshold < 1 || threshold > 100)) {
|
|
@@ -1301,22 +1437,12 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
|
|
|
1301
1437
|
...(tag !== undefined ? { tag } : {}),
|
|
1302
1438
|
});
|
|
1303
1439
|
const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
|
|
1304
|
-
console.log(`
|
|
1440
|
+
console.log(`Near-limit notifications for ${providerId} ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
|
|
1305
1441
|
if (cfg.enabled) {
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
console.warn("⚠️ Usage-policy warning: quota-triggered rotation across accounts can violate\n" +
|
|
1311
|
-
" the providers' usage policies — it pools separate subscriptions to route around\n" +
|
|
1312
|
-
" per-account rate limits. An internal review unanimously recommended removing it;\n" +
|
|
1313
|
-
" it ships off-by-default anyway. See the README before enabling. (Per-context\n" +
|
|
1314
|
-
" profile switching — private / work / client — is unaffected; this applies only\n" +
|
|
1315
|
-
" to automated quota-driven rotation.)");
|
|
1316
|
-
const strat = readSwitchStrategy();
|
|
1317
|
-
console.log(`Strategy: ${strat}${strat === "reset-first" ? " (redeem a banked reset before switching, Codex)" : ""}.\n` +
|
|
1318
|
-
`The daemon moves the active ${providerId} profile to the account with the most headroom once the\n` +
|
|
1319
|
-
"active one hits the threshold. Run `agent-switch service start` so the daemon is watching.");
|
|
1442
|
+
console.log(`Once the active ${providerId} profile hits the threshold, the daemon NOTIFIES you and SUGGESTS the\n` +
|
|
1443
|
+
"account with the most headroom — it never switches automatically; switching stays a manual action.\n" +
|
|
1444
|
+
"On Codex it redeems a banked reset first (and stays on the account) before suggesting a switch.\n" +
|
|
1445
|
+
"Run `agent-switch service start` so the daemon is watching.");
|
|
1320
1446
|
}
|
|
1321
1447
|
return;
|
|
1322
1448
|
}
|
|
@@ -1326,15 +1452,14 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
|
|
|
1326
1452
|
console.log(JSON.stringify(readAutoSwitchAll()));
|
|
1327
1453
|
return;
|
|
1328
1454
|
}
|
|
1329
|
-
console.log(`strategy: ${readSwitchStrategy()}`);
|
|
1330
1455
|
for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
|
|
1331
1456
|
const cfg = readAutoSwitch(p);
|
|
1332
1457
|
const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
|
|
1333
|
-
console.log(`${p}:
|
|
1458
|
+
console.log(`${p}: near-limit notifications ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
|
|
1334
1459
|
}
|
|
1335
1460
|
return;
|
|
1336
1461
|
}
|
|
1337
|
-
die("usage: agent-switch autoswitch on|off|status
|
|
1462
|
+
die("usage: agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] [--tag all|work|personal|other] [--json]");
|
|
1338
1463
|
}
|
|
1339
1464
|
/**
|
|
1340
1465
|
* Enable/disable a provider's surfaces (cli / ui), or show the current setting.
|
|
@@ -1551,8 +1676,8 @@ async function cmdGui() {
|
|
|
1551
1676
|
// Launch the tray/menubar GUI — downloads its prebuilt artifact from the
|
|
1552
1677
|
// matching GitHub Release on first use, then caches it (see gui-launch.ts).
|
|
1553
1678
|
try {
|
|
1554
|
-
await launchGui();
|
|
1555
|
-
console.log("Launched the agent-switch GUI.");
|
|
1679
|
+
const result = await launchGui();
|
|
1680
|
+
console.log(result === "already-running" ? "The agent-switch GUI is already running." : "Launched the agent-switch GUI.");
|
|
1556
1681
|
}
|
|
1557
1682
|
catch (err) {
|
|
1558
1683
|
die(String(err?.message ?? err));
|
|
@@ -1622,6 +1747,63 @@ function cmdOpen(appId, name) {
|
|
|
1622
1747
|
console.log("is saved to this profile. Quit other windows of this app before signing in.");
|
|
1623
1748
|
}
|
|
1624
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
|
+
}
|
|
1625
1807
|
function cmdShellenv(shellArg) {
|
|
1626
1808
|
console.log(shellenvScript(detectShell(shellArg)));
|
|
1627
1809
|
}
|
|
@@ -1637,6 +1819,61 @@ function cmdTooling(json = false) {
|
|
|
1637
1819
|
console.log(`agent-switch tooling — platform ${process.platform}\n`);
|
|
1638
1820
|
for (const l of formatToolingLines(tools))
|
|
1639
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.`);
|
|
1640
1877
|
}
|
|
1641
1878
|
/** `agent-switch tooling install|upgrade <id>` — run the tool's per-platform
|
|
1642
1879
|
* install/upgrade command as a visible child process (the GUI runs this inside
|
|
@@ -1683,6 +1920,7 @@ function usage() {
|
|
|
1683
1920
|
console.log(`agent-switch — switch accounts for Claude Code, Codex, and Antigravity (macOS · Linux · Windows)
|
|
1684
1921
|
|
|
1685
1922
|
Provider defaults to claude; pass --provider codex|antigravity for the others.
|
|
1923
|
+
Run with no command to launch the tray/menubar GUI (single-instance).
|
|
1686
1924
|
|
|
1687
1925
|
agent-switch add [--provider P] <name> create a profile and log it in
|
|
1688
1926
|
agent-switch import [--provider P] <name> migrate the default install (no re-login)
|
|
@@ -1693,39 +1931,191 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
|
|
|
1693
1931
|
agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
|
|
1694
1932
|
agent-switch current [--provider P] show the active profile(s)
|
|
1695
1933
|
agent-switch whoami [--provider P] [name] show a profile's account identity
|
|
1696
|
-
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)
|
|
1697
1936
|
agent-switch map [--provider P] <name> [dir] map a directory to a profile
|
|
1698
1937
|
agent-switch unmap [--provider P] [dir] remove a directory mapping
|
|
1699
1938
|
agent-switch mappings list directory mappings
|
|
1700
1939
|
agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
|
|
1940
|
+
agent-switch config export|import <profile> config-only cross-machine bundle to stdout / from stdin (settings, keybindings, CLAUDE.md, skills/, commands/, agents/ — NEVER credentials; pipe export | gpg -c)
|
|
1701
1941
|
agent-switch sessions [profile] [--recent N] [--json] recent + live Claude sessions per profile (with context %)
|
|
1702
1942
|
agent-switch hooks install|uninstall|status [profile] manage lifecycle push hooks in Claude settings.json
|
|
1703
1943
|
agent-switch alerts on|off|status [--threshold 80,95] record context/usage crossings to the notification log (off by default)
|
|
1704
1944
|
agent-switch compact <profile> [--clear] [--dry-run] [--force] type /compact (or /clear) into the profile's managed tmux pane
|
|
1705
1945
|
agent-switch takeover <id> --to <profile> [--from <profile>] [--keep-source] [--in-place] [--print-only] [--force] move a session to another profile and resume it
|
|
1946
|
+
agent-switch rebind [<account>] [--profile <p>] | rebind --restore [--profile <p>] | rebind --reset switch a RUNNING profile's account (macOS; no <account> = interactive picker; --restore reverses; --reset re-enables after a circuit-breaker trip)
|
|
1706
1947
|
agent-switch web <name> claude.ai in a persistent browser (Claude)
|
|
1707
1948
|
agent-switch remove [--provider P] <name> [--force] delete a profile
|
|
1708
1949
|
agent-switch label [--provider P] <name> [Work|Personal|Other|none] tag a profile
|
|
1709
|
-
agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>]
|
|
1950
|
+
agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] notify + suggest near a usage limit; never switches automatically (default OFF)
|
|
1710
1951
|
agent-switch reset <profile> --provider codex redeem one banked Codex rate-limit reset
|
|
1711
1952
|
agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
|
|
1712
1953
|
agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
|
|
1713
|
-
agent-switch gui
|
|
1954
|
+
agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
|
|
1714
1955
|
agent-switch check-update [--json] check GitHub Releases for a newer version
|
|
1715
1956
|
agent-switch update self-update to the latest version (via npm)
|
|
1716
1957
|
agent-switch apps list launchable GUI apps (macOS)
|
|
1717
1958
|
agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
|
|
1718
1959
|
agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
|
|
1719
1960
|
agent-switch service run|start|stop|status|install|uninstall background usage daemon
|
|
1720
|
-
agent-switch notifications [clear] [--json] recent notifications (
|
|
1961
|
+
agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
|
|
1721
1962
|
agent-switch notify --kind K --title T --message M [--json] record a notification event
|
|
1722
1963
|
agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
|
|
1723
1964
|
agent-switch uninstall [--force] remove all agent-switch data + daemon
|
|
1724
1965
|
agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
|
|
1725
1966
|
agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
|
|
1726
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)
|
|
1727
1969
|
agent-switch doctor per-OS, per-provider self-check`);
|
|
1728
1970
|
}
|
|
1971
|
+
/**
|
|
1972
|
+
* The interactive limit dialog (Phase 4) — `agent-switch rebind` with no account
|
|
1973
|
+
* and no `--restore`. Gathers the OTHER claude profiles + their usage, pre-selects
|
|
1974
|
+
* the best-headroom profile as a convenience, and prompts the user to pick. Only
|
|
1975
|
+
* an explicit selection switches (via `rebind`); cancel stays put. The user
|
|
1976
|
+
* interaction IS the compliance line — there is NO automatic switch anywhere in
|
|
1977
|
+
* here. macOS-only for the actual switch; a non-TTY stdin prints the suggested
|
|
1978
|
+
* command instead of hanging on a prompt no one can answer.
|
|
1979
|
+
*/
|
|
1980
|
+
async function rebindDialog(running) {
|
|
1981
|
+
const others = listProfiles("claude").filter((n) => n !== running);
|
|
1982
|
+
const candidates = [];
|
|
1983
|
+
for (const n of others) {
|
|
1984
|
+
// Same usage read path `status` uses (daemon cache when fresh, else the
|
|
1985
|
+
// OAuth /usage endpoint); null when unreadable — the row shows no %.
|
|
1986
|
+
candidates.push({ name: n, snapshot: await claudeSnapshot(n), label: labelFor("claude", n) });
|
|
1987
|
+
}
|
|
1988
|
+
const dialog = buildRebindDialog(candidates, running);
|
|
1989
|
+
const rows = selectableRows(dialog);
|
|
1990
|
+
if (rows.length === 0) {
|
|
1991
|
+
die(`no other claude profile to rebind "${running}" to — add one with \`agent-switch add <name>\`.`);
|
|
1992
|
+
}
|
|
1993
|
+
// Non-interactive stdin: never hang. Print the pre-selected profile + the exact
|
|
1994
|
+
// command that performs the switch, then return (no switch — the user has to run it).
|
|
1995
|
+
if (!process.stdin.isTTY) {
|
|
1996
|
+
const sug = dialog.suggestedProfile;
|
|
1997
|
+
if (sug) {
|
|
1998
|
+
console.error(`Suggested profile (most headroom): ${sug}`);
|
|
1999
|
+
console.error(`Switch "${running}" to it with: agent-switch rebind ${sug} --profile ${running}`);
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
console.error(`No usage data to pre-select a profile. Switch with: agent-switch rebind <account> --profile ${running}`);
|
|
2003
|
+
}
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
// The actual switch is macOS-only (the Linux/Windows credential backend is
|
|
2007
|
+
// unproven — spike R0.1). Refuse before prompting rather than after a pick.
|
|
2008
|
+
if (process.platform !== "darwin") {
|
|
2009
|
+
die("rebind is macOS-only for now (the Linux/Windows credential backend is unproven — spike R0.1).");
|
|
2010
|
+
}
|
|
2011
|
+
console.log(`Rebind "${running}" to which account? A running Claude session adopts it on its next message.`);
|
|
2012
|
+
rows.forEach((r, i) => {
|
|
2013
|
+
const marker = r.profile === dialog.suggestedProfile ? " ← suggested (most headroom)" : "";
|
|
2014
|
+
const label = r.label ? ` [${r.label}]` : "";
|
|
2015
|
+
const util = r.maxUtil === null ? "usage n/a" : `${r.maxUtil}% used`;
|
|
2016
|
+
console.log(` ${i + 1}) ${(r.profile + label).padEnd(24)} ${util}${marker}`);
|
|
2017
|
+
});
|
|
2018
|
+
const enterHint = dialog.suggestedProfile ?? "(no suggestion)";
|
|
2019
|
+
console.log(` [enter] = ${enterHint} · q = cancel (stay on "${running}")`);
|
|
2020
|
+
const SIGINT = "\u0003"; // sentinel distinct from empty input (empty = accept the suggestion)
|
|
2021
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2022
|
+
const answer = await new Promise((resolve) => {
|
|
2023
|
+
rl.on("SIGINT", () => resolve(SIGINT)); // Ctrl-C -> cancel, no switch
|
|
2024
|
+
rl.question("> ", (a) => resolve(a));
|
|
2025
|
+
});
|
|
2026
|
+
rl.close();
|
|
2027
|
+
if (answer === SIGINT) {
|
|
2028
|
+
console.log(`\nStayed on "${running}" — no switch.`);
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
const choice = resolveRebindChoice(answer, dialog);
|
|
2032
|
+
if (choice.kind === "cancel") {
|
|
2033
|
+
console.log(`Stayed on "${running}" — no switch.`);
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
if (choice.kind === "invalid") {
|
|
2037
|
+
console.log(`No valid selection — stayed on "${running}".`);
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
try {
|
|
2041
|
+
const r = await rebind({ account: choice.profile, profile: running });
|
|
2042
|
+
console.log(r.uxNote);
|
|
2043
|
+
}
|
|
2044
|
+
catch (e) {
|
|
2045
|
+
die(e.message);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* `agent-switch rebind <account> [--profile <p>]` — point a RUNNING profile's
|
|
2050
|
+
* credential store at another account, so a live Claude session adopts it on its
|
|
2051
|
+
* next message (macOS; the user-interaction switch, ADR-003). `--restore` reverses
|
|
2052
|
+
* it. With NO account and NO `--restore`, opens the interactive limit dialog
|
|
2053
|
+
* ({@link rebindDialog}). Claude only; the target + running profiles must be logged in.
|
|
2054
|
+
*/
|
|
2055
|
+
async function cmdRebind(providerId, providerExplicit, account, flags) {
|
|
2056
|
+
if (providerExplicit && providerId !== "claude")
|
|
2057
|
+
die(`rebind supports claude only (not ${providerId})`);
|
|
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
|
+
}
|
|
2078
|
+
if (flags.reset) {
|
|
2079
|
+
// Kill-switch reset (ADR-003): clear `disabled` + the failure counter so a
|
|
2080
|
+
// circuit-breaker trip (or a manual disable) no longer refuses rebind.
|
|
2081
|
+
resetRebindKillSwitch();
|
|
2082
|
+
console.log("rebind re-enabled — circuit-breaker cleared (disabled=false, consecutiveFailures=0).");
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
if (flags.restore) {
|
|
2086
|
+
if (!profile)
|
|
2087
|
+
die("usage: agent-switch rebind --restore [--profile <p>] — no active claude profile to restore");
|
|
2088
|
+
requireProfile("claude", profile, "rebind --restore --profile");
|
|
2089
|
+
try {
|
|
2090
|
+
const r = await restoreRebind({ profile });
|
|
2091
|
+
console.log(`Restored "${r.restoredProfile}" to its own account (was rebound to "${r.wasBoundTo}").`);
|
|
2092
|
+
}
|
|
2093
|
+
catch (e) {
|
|
2094
|
+
die(e.message);
|
|
2095
|
+
}
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (!account) {
|
|
2099
|
+
// No account + no --restore → the interactive limit dialog (Phase 4). The
|
|
2100
|
+
// switch is user-interaction-gated: the picker pre-selects the best-headroom
|
|
2101
|
+
// profile as a convenience, but only an explicit pick switches.
|
|
2102
|
+
if (!profile)
|
|
2103
|
+
die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
|
|
2104
|
+
requireProfile("claude", profile, "rebind --profile");
|
|
2105
|
+
return rebindDialog(profile);
|
|
2106
|
+
}
|
|
2107
|
+
if (!profile)
|
|
2108
|
+
die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
|
|
2109
|
+
requireProfile("claude", account, "rebind");
|
|
2110
|
+
requireProfile("claude", profile, "rebind --profile");
|
|
2111
|
+
try {
|
|
2112
|
+
const r = await rebind({ account, profile });
|
|
2113
|
+
console.log(r.uxNote);
|
|
2114
|
+
}
|
|
2115
|
+
catch (e) {
|
|
2116
|
+
die(e.message);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
1729
2119
|
// ---------- main -------------------------------------------------------------
|
|
1730
2120
|
async function main() {
|
|
1731
2121
|
const argv = process.argv.slice(2);
|
|
@@ -1740,12 +2130,16 @@ async function main() {
|
|
|
1740
2130
|
// profiles) skip it.
|
|
1741
2131
|
if (cmd === "__hook-event")
|
|
1742
2132
|
return cmdHookEvent(); // internal, silent, no migration
|
|
1743
|
-
|
|
2133
|
+
// A bare `agent-switch` (no command) launches the GUI, which reads profiles —
|
|
2134
|
+
// so it runs the migration too. Only pure help / shellenv skip it.
|
|
2135
|
+
if (cmd === undefined || !["help", "--help", "-h", "shellenv"].includes(cmd)) {
|
|
1744
2136
|
const moved = migrateLegacyLayout();
|
|
1745
2137
|
if (moved.length > 0) {
|
|
1746
2138
|
// stderr, not stdout: `dir` is machine-consumed by the shell wrapper
|
|
1747
|
-
// (`dir="$(agent-switch dir
|
|
1748
|
-
// 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.
|
|
1749
2143
|
console.error(`Migrated ${moved.length} Claude profile(s) to the new layout: ${moved.join(", ")}.`);
|
|
1750
2144
|
}
|
|
1751
2145
|
}
|
|
@@ -1755,7 +2149,7 @@ async function main() {
|
|
|
1755
2149
|
case "use": return cmdUse(providerId, positional[0]);
|
|
1756
2150
|
case "deactivate": return cmdDeactivate(providerId);
|
|
1757
2151
|
case "label": return cmdLabel(providerId, positional[0], positional[1]);
|
|
1758
|
-
case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags
|
|
2152
|
+
case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
|
|
1759
2153
|
case "reset": return cmdReset(providerId, positional[0]);
|
|
1760
2154
|
case "rename": return cmdRename(providerId, positional[0], positional[1]);
|
|
1761
2155
|
case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
|
|
@@ -1780,11 +2174,13 @@ async function main() {
|
|
|
1780
2174
|
case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
|
|
1781
2175
|
case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
|
|
1782
2176
|
case "whoami": return cmdWhoami(providerId, positional[0]);
|
|
1783
|
-
case "dir": return cmdDir(providerId);
|
|
2177
|
+
case "dir": return cmdDir(providerId, !!flags.guard);
|
|
2178
|
+
case "guard": return cmdGuard(positional[0], !!flags.json);
|
|
1784
2179
|
case "map": return cmdMap(providerId, positional[0], positional[1]);
|
|
1785
2180
|
case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
|
|
1786
2181
|
case "mappings": return cmdMappings();
|
|
1787
2182
|
case "share": return cmdShare(positional[0], rest.slice(1));
|
|
2183
|
+
case "config": return cmdConfig(positional[0], positional[1], flags);
|
|
1788
2184
|
case "sessions":
|
|
1789
2185
|
if (positional[0] === "rm")
|
|
1790
2186
|
return cmdSessionsRm(providerId, providerExplicit, positional[1], flags);
|
|
@@ -1797,6 +2193,7 @@ async function main() {
|
|
|
1797
2193
|
case "alerts": return cmdAlerts(positional[0], flags);
|
|
1798
2194
|
case "compact": return cmdCompact(providerId, positional[0], flags);
|
|
1799
2195
|
case "takeover": return cmdTakeover(providerId, providerExplicit, positional[0], flags);
|
|
2196
|
+
case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
|
|
1800
2197
|
case "web": return cmdWeb(positional[0]);
|
|
1801
2198
|
case "remove":
|
|
1802
2199
|
case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
|
|
@@ -1807,17 +2204,23 @@ async function main() {
|
|
|
1807
2204
|
if (positional[0] === "install" || positional[0] === "upgrade") {
|
|
1808
2205
|
return cmdToolingAction(positional[0], positional[1]);
|
|
1809
2206
|
}
|
|
2207
|
+
if (positional[0] === "notes")
|
|
2208
|
+
return cmdToolingNotes(positional[1], flags);
|
|
1810
2209
|
if (positional[0] !== undefined) {
|
|
1811
|
-
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]`);
|
|
1812
2211
|
}
|
|
1813
2212
|
return cmdTooling(!!flags.json);
|
|
1814
2213
|
case "doctor": return process.exit(await runDoctor());
|
|
1815
2214
|
case "help":
|
|
1816
2215
|
case "--help":
|
|
1817
2216
|
case "-h": return usage();
|
|
2217
|
+
// A bare `agent-switch` (no command) launches the GUI; an unknown command
|
|
2218
|
+
// still prints usage and exits non-zero.
|
|
1818
2219
|
default:
|
|
2220
|
+
if (cmd === undefined)
|
|
2221
|
+
return cmdGui();
|
|
1819
2222
|
usage();
|
|
1820
|
-
process.exit(
|
|
2223
|
+
process.exit(1);
|
|
1821
2224
|
}
|
|
1822
2225
|
}
|
|
1823
2226
|
main().catch((err) => die(String(err?.message ?? err)));
|