@event4u/agent-switch 2.1.0 → 2.3.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 +166 -73
- package/dist/activity.js +99 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +159 -0
- package/dist/balance.js +55 -0
- package/dist/browsers.js +90 -0
- package/dist/daemon.js +95 -42
- package/dist/desktop-sessions.js +115 -0
- package/dist/desktop-settings.js +90 -0
- package/dist/dir-resolve.js +178 -0
- package/dist/doctor.js +239 -69
- package/dist/gui-settings.js +67 -0
- package/dist/history.js +20 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +879 -68
- package/dist/launchers.js +228 -0
- package/dist/locks.js +61 -0
- package/dist/mappings.js +48 -9
- package/dist/pools.js +157 -0
- package/dist/profiles.js +181 -67
- package/dist/protection.js +100 -0
- package/dist/rebind.js +115 -12
- package/dist/service.js +40 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +122 -21
- package/dist/single-load.js +5 -0
- package/dist/token-freshen.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy one desktop profile's local settings into another (Claude Desktop):
|
|
3
|
+
* MCP servers, preferences and installed extensions. Chats, Projects and
|
|
4
|
+
* memory live with the account on the server, so they are not here at all.
|
|
5
|
+
*
|
|
6
|
+
* What is never copied: `config.json` (it holds the OAuth token cache),
|
|
7
|
+
* cookies and web storage (the login), session folders (bound to an account
|
|
8
|
+
* id), and preference keys scoped to one account (`…ByAccount`) or to the
|
|
9
|
+
* target's own files (`coworkUserFilesPath`).
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
const LAYOUTS = {
|
|
15
|
+
"claude-desktop": {
|
|
16
|
+
merged: ["claude_desktop_config.json", "claude_desktop_config.mcp.json"],
|
|
17
|
+
replaced: ["Claude Extensions", "Claude Extensions Settings"],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
/** The app's own data dir outside agent-switch (the plain, unprofiled install). */
|
|
21
|
+
export const DEFAULT_DATA_DIR = {
|
|
22
|
+
"claude-desktop": path.posix.join(os.homedir(), "Library", "Application Support", "Claude"),
|
|
23
|
+
};
|
|
24
|
+
export function hasSettingsLayout(appId) {
|
|
25
|
+
return appId in LAYOUTS;
|
|
26
|
+
}
|
|
27
|
+
function isObject(v) {
|
|
28
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
export function mergeDesktopConfig(src, dst) {
|
|
31
|
+
const out = { ...dst };
|
|
32
|
+
if (src.mcpServers !== undefined)
|
|
33
|
+
out.mcpServers = src.mcpServers;
|
|
34
|
+
if (isObject(src.preferences)) {
|
|
35
|
+
const prefs = isObject(dst.preferences) ? { ...dst.preferences } : {};
|
|
36
|
+
for (const [k, v] of Object.entries(src.preferences))
|
|
37
|
+
if (!k.endsWith("ByAccount"))
|
|
38
|
+
prefs[k] = v;
|
|
39
|
+
out.preferences = prefs;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function readJson(file) {
|
|
44
|
+
try {
|
|
45
|
+
const v = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
46
|
+
return isObject(v) ? v : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function backup(file) {
|
|
53
|
+
let to = `${file}.pre-copy`;
|
|
54
|
+
if (fs.existsSync(to))
|
|
55
|
+
to = `${file}.pre-copy-${Date.now()}`;
|
|
56
|
+
fs.cpSync(file, to, { recursive: true });
|
|
57
|
+
return to;
|
|
58
|
+
}
|
|
59
|
+
export function copyDesktopSettings(appId, fromDir, toDir) {
|
|
60
|
+
const layout = LAYOUTS[appId];
|
|
61
|
+
if (!layout)
|
|
62
|
+
throw new Error(`${appId} has no settings layout agent-switch knows how to copy.`);
|
|
63
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
64
|
+
const copied = [];
|
|
65
|
+
const backups = [];
|
|
66
|
+
for (const name of layout.merged) {
|
|
67
|
+
const src = readJson(path.join(fromDir, name));
|
|
68
|
+
if (!src)
|
|
69
|
+
continue;
|
|
70
|
+
const dstFile = path.join(toDir, name);
|
|
71
|
+
const dst = fs.existsSync(dstFile) ? readJson(dstFile) : null;
|
|
72
|
+
if (fs.existsSync(dstFile))
|
|
73
|
+
backups.push(backup(dstFile));
|
|
74
|
+
fs.writeFileSync(dstFile, JSON.stringify(mergeDesktopConfig(src, dst ?? {}), null, 2) + "\n");
|
|
75
|
+
copied.push(name);
|
|
76
|
+
}
|
|
77
|
+
for (const name of layout.replaced) {
|
|
78
|
+
const src = path.join(fromDir, name);
|
|
79
|
+
if (!fs.existsSync(src))
|
|
80
|
+
continue;
|
|
81
|
+
const dst = path.join(toDir, name);
|
|
82
|
+
if (fs.existsSync(dst)) {
|
|
83
|
+
backups.push(backup(dst));
|
|
84
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
87
|
+
copied.push(name);
|
|
88
|
+
}
|
|
89
|
+
return { copied, backups };
|
|
90
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which profile a new session in a folder gets, and why — the logic behind
|
|
3
|
+
* `agent-switch dir` (the shell wrapper's hot path) and `dir --json`.
|
|
4
|
+
*
|
|
5
|
+
* Order: folder rule (to a profile, or to a pool) > balanced plain start > active
|
|
6
|
+
* profile > none. A rule never resolves outside itself: a disabled or missing
|
|
7
|
+
* profile, like a pool that cannot serve, refuses the start (`blocked`).
|
|
8
|
+
* A pool resolves to the member with the most headroom on FRESH cached usage.
|
|
9
|
+
* Unknown usage is never treated as free: a member whose usage is older than
|
|
10
|
+
* {@link FRESH_USAGE_MS}, missing, or who is signed out is skipped. A pool rule
|
|
11
|
+
* never leaves its pool: with no fresh member it takes a usable member anyway,
|
|
12
|
+
* and when the pool is gone or has no usable member the start is refused
|
|
13
|
+
* (`blocked`) rather than run on an account outside it. No
|
|
14
|
+
* network call happens here — only the cache the daemon / `status` already wrote.
|
|
15
|
+
* The background service (`agent-switch service`) is what keeps that cache
|
|
16
|
+
* fresh for pool members: it polls every member of a pool a folder rule targets.
|
|
17
|
+
*
|
|
18
|
+
* Live sessions: under guard `block` a member that already holds a live
|
|
19
|
+
* session is skipped (starting there would be refused). Otherwise a free member
|
|
20
|
+
* wins a headroom tie over a busy one, and a busy pick is called out.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, level, weigh } from "./balance.js";
|
|
23
|
+
import { isPoolRef, poolIdOf, parseMember } from "./pools.js";
|
|
24
|
+
export const FRESH_USAGE_MS = 10 * 60_000;
|
|
25
|
+
export const DEFAULT_LIMIT_PCT = 95;
|
|
26
|
+
export function ago(ms) {
|
|
27
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
28
|
+
if (s < 60)
|
|
29
|
+
return "just now";
|
|
30
|
+
const m = Math.round(s / 60);
|
|
31
|
+
if (m < 60)
|
|
32
|
+
return `${m} min ago`;
|
|
33
|
+
const h = Math.round(m / 60);
|
|
34
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
|
|
35
|
+
}
|
|
36
|
+
function fallbackToActive(i, why) {
|
|
37
|
+
if (i.active && i.exists(i.active)) {
|
|
38
|
+
why.push(`Using the active profile ${i.providerId}/${i.active}`);
|
|
39
|
+
return { name: i.active, why };
|
|
40
|
+
}
|
|
41
|
+
why.push("No active profile — the tool starts with its own default login");
|
|
42
|
+
return { name: null, why };
|
|
43
|
+
}
|
|
44
|
+
function refuse(why, reason) {
|
|
45
|
+
why.push(`Refused: ${reason}`);
|
|
46
|
+
return { name: null, why, blocked: reason };
|
|
47
|
+
}
|
|
48
|
+
export function resolveForDir(i) {
|
|
49
|
+
const shown = (p) => (i.displayPath ? i.displayPath(p) : p);
|
|
50
|
+
const why = [];
|
|
51
|
+
if (i.mapping && isPoolRef(i.mapping.name)) {
|
|
52
|
+
const id = poolIdOf(i.mapping.name);
|
|
53
|
+
why.push(`Rule: folder ${shown(i.mapping.path)} → pool ${id}`);
|
|
54
|
+
const pool = i.pool(id);
|
|
55
|
+
if (!pool)
|
|
56
|
+
return refuse(why, `pool ${id} no longer exists — bind the folder anew or remove the rule (agent-switch unmap)`);
|
|
57
|
+
const considered = pool.members.map(parseMember).filter((m) => m && m.provider === i.providerId);
|
|
58
|
+
if (considered.length === 0)
|
|
59
|
+
return refuse(why, `pool ${id} has no ${i.providerId} profile — add one with agent-switch pool add`);
|
|
60
|
+
const members = considered.map((m) => m.name);
|
|
61
|
+
const best = pickBalanced(i, members, why, null);
|
|
62
|
+
if (best)
|
|
63
|
+
return { name: best, why };
|
|
64
|
+
// No fresh numbers to weigh: still a member — the rule is a boundary first.
|
|
65
|
+
const usable = (n) => i.exists(n) && !i.disabled?.(n) && !i.sharesDefaultLogin?.(n) && i.signedIn(n) && !(i.guard === "block" && (i.live?.(n) ?? false));
|
|
66
|
+
const anyone = members.filter(usable);
|
|
67
|
+
if (!anyone.length)
|
|
68
|
+
return refuse(why, `pool ${id} has no usable ${i.providerId} member (signed out, disabled, busy or sharing the default login)`);
|
|
69
|
+
why.push(`No member of pool ${id} has fresh usage`);
|
|
70
|
+
why.push("Usage comes from the background service or the open desktop app — keep one running (agent-switch service install)");
|
|
71
|
+
if (i.active && anyone.includes(i.active)) {
|
|
72
|
+
why.push(`Using the plain-start account ${i.providerId}/${i.active}, a member of pool ${id}`);
|
|
73
|
+
return { name: i.active, why };
|
|
74
|
+
}
|
|
75
|
+
why.push(`Using ${i.providerId}/${anyone[0]}, the first usable member of pool ${id}`);
|
|
76
|
+
return { name: anyone[0], why };
|
|
77
|
+
}
|
|
78
|
+
// A rule to one account is a boundary too: never another account in its place.
|
|
79
|
+
if (i.mapping) {
|
|
80
|
+
const key = `${i.providerId}/${i.mapping.name}`;
|
|
81
|
+
const flag = i.providerId === "claude" ? "" : ` --provider ${i.providerId}`;
|
|
82
|
+
why.push(`Rule: folder ${shown(i.mapping.path)} → ${key}`);
|
|
83
|
+
if (!i.exists(i.mapping.name))
|
|
84
|
+
return refuse(why, `${key} no longer exists — bind the folder anew (agent-switch map) or remove the rule (agent-switch unmap)`);
|
|
85
|
+
if (i.disabled?.(i.mapping.name))
|
|
86
|
+
return refuse(why, `${key} is disabled — turn it back on (agent-switch enable ${i.mapping.name}${flag}) or change the rule`);
|
|
87
|
+
return { name: i.mapping.name, why };
|
|
88
|
+
}
|
|
89
|
+
return balancePlain(i, why);
|
|
90
|
+
}
|
|
91
|
+
/** No rule decides: balance across all profiles, or the active one when the
|
|
92
|
+
* caller gave no profile list. */
|
|
93
|
+
function balancePlain(i, why) {
|
|
94
|
+
if (!i.names)
|
|
95
|
+
return fallbackToActive(i, why);
|
|
96
|
+
const n = i.names.length;
|
|
97
|
+
why.push(`No folder rule — balancing across ${n} ${i.providerId} account${n === 1 ? "" : "s"} by what each would lose at its reset`);
|
|
98
|
+
const best = pickBalanced(i, i.names, why, i.active);
|
|
99
|
+
if (best)
|
|
100
|
+
return { name: best, why };
|
|
101
|
+
why.push("No account has fresh usage to weigh — using the plain-start account");
|
|
102
|
+
return fallbackToActive(i, why);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The candidate that would lose the most at its reset (see balance.ts), after
|
|
106
|
+
* dropping every profile that can't take work. A level tie goes to `prefer`
|
|
107
|
+
* (the plain-start account), then to one without a live session.
|
|
108
|
+
*/
|
|
109
|
+
function pickBalanced(i, names, why, prefer) {
|
|
110
|
+
const isLive = i.live ?? (() => false);
|
|
111
|
+
const guard = i.guard ?? "off";
|
|
112
|
+
const picks = [];
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
const key = `${i.providerId}/${name}`;
|
|
115
|
+
if (!i.exists(name)) {
|
|
116
|
+
why.push(`Skipped ${key}: profile not found`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (i.disabled?.(name)) {
|
|
120
|
+
why.push(`Skipped ${key}: disabled`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (i.sharesDefaultLogin?.(name)) {
|
|
124
|
+
why.push(`Skipped ${key}: shares its login with the default install`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!i.signedIn(name)) {
|
|
128
|
+
why.push(`Skipped ${key}: signed out`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const live = isLive(name);
|
|
132
|
+
if (live && guard === "block") {
|
|
133
|
+
why.push(`Skipped ${key}: already has a live session (guard: block would refuse it)`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const u = i.usage(name);
|
|
137
|
+
if (u.capturedMs === null || !u.snapshot) {
|
|
138
|
+
why.push(`Skipped ${key}: usage unknown`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const ageMs = i.now - u.capturedMs;
|
|
142
|
+
if (ageMs > FRESH_USAGE_MS) {
|
|
143
|
+
why.push(`Skipped ${key}: usage checked ${ago(ageMs)} (too old to trust)`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const w = weigh(u.snapshot, i.now, i.threshold?.(name) ?? DEFAULT_LIMIT_PCT);
|
|
147
|
+
if (!w.usable) {
|
|
148
|
+
why.push(`Skipped ${key}: ${w.why}`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
picks.push({ name, w, live, ageMs });
|
|
152
|
+
}
|
|
153
|
+
if (!picks.length)
|
|
154
|
+
return null;
|
|
155
|
+
const top = picks.reduce((a, b) => (b.w.need > a.w.need ? b : a));
|
|
156
|
+
const tied = picks.filter((p) => level(p.w.need, top.w.need));
|
|
157
|
+
let chosen = top;
|
|
158
|
+
if (tied.length > 1) {
|
|
159
|
+
const free = tied.filter((p) => !p.live);
|
|
160
|
+
const roomiest = (ps) => ps.reduce((a, b) => (b.w.peak < a.w.peak ? b : a));
|
|
161
|
+
chosen = tied.find((p) => p.name === prefer) ?? (free.length ? roomiest(free) : roomiest(tied));
|
|
162
|
+
const keys = tied.map((p) => `${i.providerId}/${p.name}`).join(", ");
|
|
163
|
+
const by = chosen.name === prefer
|
|
164
|
+
? "the plain-start account"
|
|
165
|
+
: !chosen.live && tied.some((p) => p.live)
|
|
166
|
+
? "the one without a live session"
|
|
167
|
+
: tied.some((p) => p.w.peak !== chosen.w.peak)
|
|
168
|
+
? "the one with the most room right now"
|
|
169
|
+
: "the first of them";
|
|
170
|
+
why.push(`${keys} are level (${top.w.need.toFixed(1)} %/h) — taking ${by}, ${i.providerId}/${chosen.name}`);
|
|
171
|
+
}
|
|
172
|
+
const runnerUp = picks.filter((p) => p !== chosen).reduce((a, b) => (!a || b.w.need > a.w.need ? b : a), null);
|
|
173
|
+
if (chosen.live)
|
|
174
|
+
why.push(`Note: ${i.providerId}/${chosen.name} already has a live session (guard: ${guard})`);
|
|
175
|
+
why.push(`Picked ${describe(`${i.providerId}/${chosen.name}`, chosen.w)} · usage checked ${ago(chosen.ageMs)}` +
|
|
176
|
+
(runnerUp && tied.length === 1 ? ` — ahead of ${i.providerId}/${runnerUp.name} (${runnerUp.w.need.toFixed(1)} %/h)` : ""));
|
|
177
|
+
return chosen.name;
|
|
178
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -1,21 +1,146 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agent-switch doctor` — a per-OS, per-provider self-check.
|
|
2
|
+
* `agent-switch doctor [--json]` — a per-OS, per-provider self-check.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Read-only — it never
|
|
4
|
+
* Every finding is a {@link DoctorCheck} (`id, status, title, detail, fix?`);
|
|
5
|
+
* the text output and `--json` render the same list. The exit code is 1 only
|
|
6
|
+
* on a hard error (a provider with profiles but no binary). Read-only — it never
|
|
7
|
+
* mutates a profile, and it never prints a credential or an env var's value.
|
|
7
8
|
*/
|
|
8
9
|
import { spawnSync } from "node:child_process";
|
|
9
10
|
import * as fs from "node:fs";
|
|
10
|
-
import
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { ROOT, activeFor, configDir, identity, listProfiles, profileExists, readGuard, sharesDefaultLogin } from "./profiles.js";
|
|
11
14
|
import { allProviders } from "./providers.js";
|
|
12
15
|
import { credentialStore } from "./credentials.js";
|
|
13
16
|
import { sharedLinkHealth } from "./share.js";
|
|
14
17
|
import { checkAuth, liveSessionPids } from "./api.js";
|
|
15
18
|
import { checkTooling, doctorToolingLine } from "./tooling.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
import { readGuiSettings } from "./gui-settings.js";
|
|
20
|
+
/**
|
|
21
|
+
* Env vars that make a provider CLI ignore the profile's login and use a key
|
|
22
|
+
* or token from the environment instead (D5) — the same credential in every
|
|
23
|
+
* profile, often billed per use. Only the NAMES are ever reported.
|
|
24
|
+
*/
|
|
25
|
+
export const AUTH_OVERRIDE_ENV = {
|
|
26
|
+
ANTHROPIC_API_KEY: "claude",
|
|
27
|
+
ANTHROPIC_AUTH_TOKEN: "claude",
|
|
28
|
+
CLAUDE_CODE_OAUTH_TOKEN: "claude",
|
|
29
|
+
OPENAI_API_KEY: "codex",
|
|
30
|
+
CODEX_API_KEY: "codex",
|
|
31
|
+
GEMINI_API_KEY: "antigravity",
|
|
32
|
+
GOOGLE_API_KEY: "antigravity",
|
|
33
|
+
};
|
|
34
|
+
export function apiKeyOverrideCheck(env = process.env) {
|
|
35
|
+
const set = Object.keys(AUTH_OVERRIDE_ENV).filter((k) => typeof env[k] === "string" && env[k] !== "");
|
|
36
|
+
if (set.length === 0) {
|
|
37
|
+
return { id: "api-key-overrides", status: "ok", title: "No API-key overrides", detail: "No provider key or token is set in the environment, so each profile uses its own login." };
|
|
38
|
+
}
|
|
39
|
+
const which = set.map((k) => `${k} (${AUTH_OVERRIDE_ENV[k]})`).join(", ");
|
|
40
|
+
return {
|
|
41
|
+
id: "api-key-overrides",
|
|
42
|
+
status: "warn",
|
|
43
|
+
title: "API key overrides profile logins",
|
|
44
|
+
detail: `${which} is set in the environment agent-switch runs in. The provider uses it instead of the profile's login — the same credential in every profile, usually billed per use.`,
|
|
45
|
+
fix: `Unset it in your shell profile (e.g. \`unset ${set[0]}\`) unless you mean to use a key.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Is `agent-switch` reachable on PATH (the shell wrapper and the GUI call it by name)? */
|
|
49
|
+
export function cliOnPathCheck(env = process.env, platform = process.platform, exists = fs.existsSync) {
|
|
50
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(platform === "win32" ? ";" : ":").filter(Boolean);
|
|
51
|
+
const names = platform === "win32" ? ["agent-switch.cmd", "agent-switch.exe", "agent-switch"] : ["agent-switch"];
|
|
52
|
+
const hit = dirs.flatMap((d) => names.map((n) => path.join(d, n))).find(exists);
|
|
53
|
+
return hit
|
|
54
|
+
? { id: "cli-on-path", status: "ok", title: "agent-switch is on PATH", detail: hit }
|
|
55
|
+
: {
|
|
56
|
+
id: "cli-on-path",
|
|
57
|
+
status: "warn",
|
|
58
|
+
title: "agent-switch is not on PATH",
|
|
59
|
+
detail: "The shell wrapper and the desktop app call `agent-switch` by name.",
|
|
60
|
+
fix: "npm install -g @event4u/agent-switch",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function rcCandidates(home) {
|
|
64
|
+
return [
|
|
65
|
+
path.join(home, ".zshrc"),
|
|
66
|
+
path.join(home, ".bashrc"),
|
|
67
|
+
path.join(home, ".bash_profile"),
|
|
68
|
+
path.join(home, ".profile"),
|
|
69
|
+
path.join(home, ".config", "fish", "config.fish"),
|
|
70
|
+
path.join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1"),
|
|
71
|
+
path.join(home, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
export function shellIntegrationCheck(home = os.homedir(), read = readOrNull) {
|
|
75
|
+
const found = rcCandidates(home).find((f) => /agent-switch\s+shellenv/.test(read(f) ?? ""));
|
|
76
|
+
return found
|
|
77
|
+
? { id: "shell-integration", status: "ok", title: "Shell integration installed", detail: `Loaded from ${found}.` }
|
|
78
|
+
: {
|
|
79
|
+
id: "shell-integration",
|
|
80
|
+
status: "warn",
|
|
81
|
+
title: "Shell integration not found",
|
|
82
|
+
detail: "Without it, `claude`/`codex`/`agy` in a terminal ignore the active profile and folder rules. (Checked the usual zsh, bash, fish and PowerShell profile files.)",
|
|
83
|
+
fix: 'Run `agent-switch shellenv install` — it adds `eval "$(agent-switch shellenv)"` (or your shell\'s equivalent) to your shell profile.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function autostartCheck(s) {
|
|
87
|
+
const a = s.autostart;
|
|
88
|
+
if (a.state === "enabled") {
|
|
89
|
+
return { id: "autostart", status: "ok", title: "Starts at login", detail: a.by === "user" ? "Turned on by you." : "On by default since the first launch." };
|
|
90
|
+
}
|
|
91
|
+
if (a.state === "disabled") {
|
|
92
|
+
return { id: "autostart", status: "info", title: "Does not start at login", detail: "Turned off by you; updates keep it off." };
|
|
93
|
+
}
|
|
94
|
+
if (s.autostartDegraded) {
|
|
95
|
+
return {
|
|
96
|
+
id: "autostart",
|
|
97
|
+
status: "warn",
|
|
98
|
+
title: "Start at login could not be confirmed",
|
|
99
|
+
detail: `The desktop app tried to turn it on but the system did not confirm it (${s.autostartDegraded.reason}). It retries on the next launch.`,
|
|
100
|
+
fix: "Open the desktop app → Settings → turn on start at login.",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { id: "autostart", status: "info", title: "Start at login not set up yet", detail: "The desktop app turns it on at its first launch (default on)." };
|
|
104
|
+
}
|
|
105
|
+
export function antigravityOsCheck(platform = process.platform) {
|
|
106
|
+
return platform === "darwin"
|
|
107
|
+
? { id: "antigravity-os", status: "ok", title: "Antigravity supported on this OS", detail: "Per-profile keychain and account readout work on macOS." }
|
|
108
|
+
: {
|
|
109
|
+
id: "antigravity-os",
|
|
110
|
+
status: "warn",
|
|
111
|
+
title: "Antigravity is only partly supported here",
|
|
112
|
+
detail: `On ${platform} agent-switch isolates agy's HOME per profile but cannot read which account is signed in or seed a per-profile keychain — both are macOS-only today.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Two profiles of one provider signed into the same account defeat the isolation. */
|
|
116
|
+
export function signInIsolationCheck(rows) {
|
|
117
|
+
const seen = new Map();
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
if (!r.identity)
|
|
120
|
+
continue;
|
|
121
|
+
const k = `${r.provider}\u0000${r.identity.toLowerCase()}`;
|
|
122
|
+
seen.set(k, [...(seen.get(k) ?? []), `${r.provider}/${r.name}`]);
|
|
123
|
+
}
|
|
124
|
+
const dupes = [...seen.values()].filter((v) => v.length > 1);
|
|
125
|
+
if (dupes.length === 0) {
|
|
126
|
+
return { id: "sign-in-isolation", status: "ok", title: "Each profile has its own account", detail: "No two profiles of one provider are signed into the same account (where the account is readable)." };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
id: "sign-in-isolation",
|
|
130
|
+
status: "warn",
|
|
131
|
+
title: "Profiles share an account",
|
|
132
|
+
detail: `Signed into the same account: ${dupes.map((d) => d.join(" + ")).join("; ")}. They share one set of usage limits.`,
|
|
133
|
+
fix: "Sign one of them into a different account, or remove the duplicate.",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function readOrNull(p) {
|
|
137
|
+
try {
|
|
138
|
+
return fs.readFileSync(p, "utf8");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
19
144
|
function binaryOnPath(binary) {
|
|
20
145
|
// Timeout so a hung binary can't hang the doctor.
|
|
21
146
|
const probe = spawnSync(binary, ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
@@ -37,42 +162,30 @@ function credentialReadable(p, name) {
|
|
|
37
162
|
return false;
|
|
38
163
|
}
|
|
39
164
|
}
|
|
40
|
-
|
|
41
|
-
let hardError = false;
|
|
42
|
-
const line = (mark, msg) => console.log(`${mark} ${msg}`);
|
|
43
|
-
console.log(`agent-switch doctor — platform ${process.platform}, node ${process.version}`);
|
|
44
|
-
console.log(process.platform === "darwin"
|
|
45
|
-
? " claude credential store: macOS Keychain (hashed per config dir), file fallback"
|
|
46
|
-
: " claude credential store: plaintext .credentials.json per profile");
|
|
47
|
-
console.log("");
|
|
165
|
+
async function providerChecks(checks) {
|
|
48
166
|
for (const p of allProviders()) {
|
|
49
167
|
const names = listProfiles(p.id);
|
|
50
168
|
const onPath = binaryOnPath(p.binary);
|
|
169
|
+
const addHint = `agent-switch add <name>${p.id === "claude" ? "" : ` --provider ${p.id}`}`;
|
|
51
170
|
if (onPath === "no") {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
line(WARN, `\`${p.binary}\` not on PATH (no ${p.id} profiles yet — fine unless you use ${p.id}).`);
|
|
58
|
-
}
|
|
171
|
+
checks.push(names.length > 0
|
|
172
|
+
? { id: `binary-${p.id}`, status: "error", title: `\`${p.binary}\` not on PATH`, detail: `${names.length} ${p.id} profile(s) exist but the CLI is missing.`, fix: `Install ${p.id}, or link it: agent-switch providers link --provider ${p.id} --path <path>` }
|
|
173
|
+
: { id: `binary-${p.id}`, status: "info", title: `\`${p.binary}\` not on PATH`, detail: `No ${p.id} profiles yet — fine unless you use ${p.id}.` });
|
|
59
174
|
continue;
|
|
60
175
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
line(OK, `\`${p.binary}\` is on PATH.`);
|
|
176
|
+
checks.push(onPath === "error"
|
|
177
|
+
? { id: `binary-${p.id}`, status: "warn", title: `Could not probe \`${p.binary} --version\``, detail: "The binary exists but did not answer." }
|
|
178
|
+
: { id: `binary-${p.id}`, status: "ok", title: `\`${p.binary}\` is on PATH`, detail: "" });
|
|
65
179
|
if (names.length === 0) {
|
|
66
|
-
|
|
180
|
+
checks.push({ id: `profiles-${p.id}`, status: "info", title: `No ${p.id} profiles yet`, detail: "", fix: addHint });
|
|
67
181
|
continue;
|
|
68
182
|
}
|
|
69
|
-
|
|
183
|
+
checks.push({ id: `profiles-${p.id}`, status: "ok", title: `${names.length} ${p.id} profile(s)`, detail: names.join(", ") });
|
|
70
184
|
const active = activeFor(p.id);
|
|
71
185
|
if (active && !profileExists(p.id, active)) {
|
|
72
|
-
|
|
186
|
+
checks.push({ id: `active-${p.id}`, status: "warn", title: `Active ${p.id} profile "${active}" no longer exists`, detail: "", fix: `agent-switch use <name>${p.id === "claude" ? "" : ` --provider ${p.id}`}` });
|
|
73
187
|
}
|
|
74
|
-
// Login validity (Claude only): probe readable credentials in parallel
|
|
75
|
-
// an expired login is caught here instead of failing silently at next use.
|
|
188
|
+
// Login validity (Claude only): probe readable credentials in parallel.
|
|
76
189
|
// Read-only; offline / transient degrades to a neutral note, never a WARN.
|
|
77
190
|
const authByName = new Map();
|
|
78
191
|
if (p.id === "claude") {
|
|
@@ -81,23 +194,66 @@ export async function runDoctor() {
|
|
|
81
194
|
readable.forEach((n, i) => authByName.set(n, states[i]));
|
|
82
195
|
}
|
|
83
196
|
for (const n of names) {
|
|
197
|
+
const id = `login-${p.id}-${n}`;
|
|
198
|
+
const run = `agent-switch run ${n}${p.id === "claude" ? "" : ` --provider ${p.id}`}`;
|
|
84
199
|
if (!credentialReadable(p, n)) {
|
|
85
|
-
|
|
200
|
+
checks.push({ id, status: "warn", title: `${p.id}/${n}: no readable login`, detail: "", fix: `${run} and sign in` });
|
|
86
201
|
continue;
|
|
87
202
|
}
|
|
88
203
|
const auth = authByName.get(n);
|
|
89
|
-
if (auth === "expired")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
else {
|
|
96
|
-
// Non-Claude (no probe) or Claude "unknown"/offline — don't over-claim.
|
|
97
|
-
line(OK, ` ${p.id}/${n}: credential readable${p.id === "claude" ? " (login not verified — offline?)" : ""}.`);
|
|
98
|
-
}
|
|
204
|
+
if (auth === "expired")
|
|
205
|
+
checks.push({ id, status: "warn", title: `${p.id}/${n}: login expired`, detail: "", fix: `${run} and /login again` });
|
|
206
|
+
else if (auth === "ok")
|
|
207
|
+
checks.push({ id, status: "ok", title: `${p.id}/${n}: login valid`, detail: "" });
|
|
208
|
+
else
|
|
209
|
+
checks.push({ id, status: "ok", title: `${p.id}/${n}: login stored`, detail: p.id === "claude" ? "Not verified — offline?" : "" });
|
|
99
210
|
}
|
|
100
211
|
}
|
|
212
|
+
}
|
|
213
|
+
/** Profiles that share their login with the default install: agent-switch
|
|
214
|
+
* never refreshes or balances onto them; a separate sign-in ends that. */
|
|
215
|
+
/** The account the provider's own default install is signed into, where it can
|
|
216
|
+
* be read without touching a credential (Antigravity's needs the keychain). */
|
|
217
|
+
function defaultIdentity(p) {
|
|
218
|
+
if (p.id === "antigravity")
|
|
219
|
+
return null;
|
|
220
|
+
// Claude keeps .claude.json in the home dir, next to (not inside) ~/.claude.
|
|
221
|
+
return p.readIdentity(p.id === "claude" ? os.homedir() : p.defaultConfigDir());
|
|
222
|
+
}
|
|
223
|
+
export function sharedLoginCheck(rows, likely = []) {
|
|
224
|
+
if (!rows.length && !likely.length)
|
|
225
|
+
return { id: "shared-login", status: "ok", title: "No profile shares its login with the default install", detail: "" };
|
|
226
|
+
const key = (r) => `${r.provider}/${r.name}`;
|
|
227
|
+
const flag = (p) => (p === "claude" ? "" : ` --provider ${p}`);
|
|
228
|
+
const parts = [];
|
|
229
|
+
if (rows.length) {
|
|
230
|
+
parts.push(`${rows.map(key).join(", ")} — imported with the default install's credential. agent-switch never refreshes ${rows.length === 1 ? "it" : "them"} (that would sign the other side out) and never starts ${rows.length === 1 ? "it" : "them"} by balancing.`);
|
|
231
|
+
}
|
|
232
|
+
// Imports from before the marker existed: same account as the default install.
|
|
233
|
+
if (likely.length) {
|
|
234
|
+
parts.push(`${likely.map(key).join(", ")} may share ${likely.length === 1 ? "its" : "their"} login with the default install (same account, no mark) — if imported, mark ${likely.length === 1 ? "it" : "them"}; if signed in on ${likely.length === 1 ? "its" : "their"} own, ignore this.`);
|
|
235
|
+
}
|
|
236
|
+
const n = rows.length + likely.length;
|
|
237
|
+
return {
|
|
238
|
+
id: "shared-login",
|
|
239
|
+
status: "warn",
|
|
240
|
+
title: rows.length ? `${n === 1 ? "A profile shares" : `${n} profiles share`} the default install's login` : "A profile may share the default install's login",
|
|
241
|
+
detail: parts.join(" "),
|
|
242
|
+
fix: rows.length ? `agent-switch login ${rows[0].name}${flag(rows[0].provider)}` : `agent-switch mark-shared ${likely[0].name}${flag(likely[0].provider)}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
export async function collectDoctorReport() {
|
|
246
|
+
const checks = [];
|
|
247
|
+
checks.push(cliOnPathCheck());
|
|
248
|
+
checks.push(shellIntegrationCheck());
|
|
249
|
+
checks.push(autostartCheck(readGuiSettings()));
|
|
250
|
+
checks.push(apiKeyOverrideCheck());
|
|
251
|
+
checks.push(antigravityOsCheck());
|
|
252
|
+
await providerChecks(checks);
|
|
253
|
+
const identities = allProviders().flatMap((p) => listProfiles(p.id).map((n) => ({ provider: p.id, name: n, identity: identity(p.id, n) })));
|
|
254
|
+
checks.push(signInIsolationCheck(identities));
|
|
255
|
+
const defaults = new Map(allProviders().map((p) => [p.id, defaultIdentity(p)]));
|
|
256
|
+
checks.push(sharedLoginCheck(identities.filter((r) => sharesDefaultLogin(r.provider, r.name)), identities.filter((r) => !sharesDefaultLogin(r.provider, r.name) && r.identity !== null && r.identity === defaults.get(r.provider))));
|
|
101
257
|
// Share-link health (Claude-config sharing).
|
|
102
258
|
for (const n of listProfiles("claude")) {
|
|
103
259
|
const health = sharedLinkHealth(configDir("claude", n));
|
|
@@ -106,42 +262,56 @@ export async function runDoctor() {
|
|
|
106
262
|
const forked = health.filter((h) => h.state === "forked").map((h) => h.name);
|
|
107
263
|
const missing = health.filter((h) => h.state === "missing").map((h) => h.name);
|
|
108
264
|
if (forked.length === 0 && missing.length === 0) {
|
|
109
|
-
|
|
265
|
+
checks.push({ id: `share-${n}`, status: "ok", title: `claude/${n}: ${health.length} shared link(s) intact`, detail: "" });
|
|
110
266
|
}
|
|
111
267
|
else {
|
|
112
268
|
const parts = [forked.length ? `forked: ${forked.join(", ")}` : "", missing.length ? `missing: ${missing.join(", ")}` : ""].filter(Boolean);
|
|
113
|
-
|
|
269
|
+
checks.push({ id: `share-${n}`, status: "warn", title: `claude/${n}: shared links drifted`, detail: parts.join("; "), fix: "agent-switch share sync" });
|
|
114
270
|
}
|
|
115
271
|
}
|
|
116
272
|
// Single-load guard: the policy, and whether any profile is doubled RIGHT NOW.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
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.`);
|
|
273
|
+
const policy = readGuard();
|
|
274
|
+
checks.push({ id: "guard", status: policy === "off" ? "warn" : "ok", title: `Single-load guard: ${policy}`, detail: policy === "off" ? "A profile can be loaded twice." : "" });
|
|
275
|
+
const doubled = [];
|
|
276
|
+
for (const p of allProviders()) {
|
|
277
|
+
if (!p.hasLiveSessionSignal)
|
|
278
|
+
continue;
|
|
279
|
+
for (const n of listProfiles(p.id)) {
|
|
280
|
+
const count = liveSessionPids(configDir(p.id, n)).length;
|
|
281
|
+
if (count > 1)
|
|
282
|
+
doubled.push(`${p.id}/${n} (${count} sessions)`);
|
|
135
283
|
}
|
|
136
284
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
285
|
+
if (doubled.length > 0) {
|
|
286
|
+
checks.push({ id: "guard-doubled", status: "warn", title: "A profile is loaded more than once", detail: `${doubled.join(", ")} — they share one account's rate limits.` });
|
|
287
|
+
}
|
|
288
|
+
// Ecosystem tooling (agent-config + rtk) — both optional, so never an error.
|
|
140
289
|
for (const t of checkTooling({ ids: ["agent-config", "rtk"] })) {
|
|
141
|
-
|
|
290
|
+
checks.push({ id: `tooling-${t.id}`, status: t.healthy ? "ok" : "warn", title: doctorToolingLine(t), detail: "" });
|
|
291
|
+
}
|
|
292
|
+
const readiness = checks.some((c) => c.status === "error") ? "blocked" : checks.some((c) => c.status === "warn") ? "attention" : "ready";
|
|
293
|
+
return { readiness, platform: process.platform, checks };
|
|
294
|
+
}
|
|
295
|
+
const MARK = { ok: "✅", warn: "⚠️", error: "❌", info: "ℹ️" };
|
|
296
|
+
export async function runDoctor(json = false) {
|
|
297
|
+
const report = await collectDoctorReport();
|
|
298
|
+
if (json) {
|
|
299
|
+
console.log(JSON.stringify(report, null, 2));
|
|
300
|
+
return report.readiness === "blocked" ? 1 : 0;
|
|
301
|
+
}
|
|
302
|
+
console.log(`agent-switch doctor — platform ${process.platform}, node ${process.version}`);
|
|
303
|
+
console.log(process.platform === "darwin"
|
|
304
|
+
? " claude credential store: macOS Keychain (hashed per config dir), file fallback"
|
|
305
|
+
: " claude credential store: plaintext .credentials.json per profile");
|
|
306
|
+
console.log("");
|
|
307
|
+
for (const c of report.checks) {
|
|
308
|
+
console.log(`${MARK[c.status]} ${c.title}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
309
|
+
if (c.fix)
|
|
310
|
+
console.log(` fix: ${c.fix}`);
|
|
142
311
|
}
|
|
143
312
|
console.log(`\n profile root: ${ROOT}`);
|
|
144
313
|
console.log("");
|
|
145
|
-
|
|
146
|
-
|
|
314
|
+
const blocked = report.readiness === "blocked";
|
|
315
|
+
console.log(`${blocked ? MARK.error : MARK.ok} ${blocked ? "doctor found a blocking problem (see above)." : "no blocking problems."}`);
|
|
316
|
+
return blocked ? 1 : 0;
|
|
147
317
|
}
|