@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.
@@ -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
- * Reports health with actionable fixes and returns an exit code: 0 when there
5
- * is no hard error, 1 when a provider that has profiles is missing its binary.
6
- * Read-only — it never mutates a profile.
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 { ROOT, activeFor, configDir, listProfiles, profileExists, readGuard } from "./profiles.js";
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
- const OK = "✅";
17
- const WARN = "⚠️";
18
- const ERR = "❌";
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
- export async function runDoctor() {
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
- if (names.length > 0) {
53
- line(ERR, `\`${p.binary}\` not on PATH but ${names.length} ${p.id} profile(s) exist — install ${p.id}.`);
54
- hardError = true;
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
- if (onPath === "error")
62
- line(WARN, `could not probe \`${p.binary} --version\`.`);
63
- else
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
- line(WARN, ` no ${p.id} profiles yet — \`agent-switch add <name>${p.id === "claude" ? "" : ` --provider ${p.id}`}\`.`);
180
+ checks.push({ id: `profiles-${p.id}`, status: "info", title: `No ${p.id} profiles yet`, detail: "", fix: addHint });
67
181
  continue;
68
182
  }
69
- line(OK, ` ${names.length} ${p.id} profile(s): ${names.join(", ")}.`);
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
- line(WARN, ` active ${p.id} profile "${active}" no longer exists.`);
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, so
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
- line(WARN, ` ${p.id}/${n}: credential not readable — \`agent-switch run ${n}${p.id === "claude" ? "" : ` --provider ${p.id}`}\` and log in.`);
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
- line(WARN, ` ${p.id}/${n}: login expired — \`agent-switch run ${n}\` and /login again.`);
91
- }
92
- else if (auth === "ok") {
93
- line(OK, ` ${p.id}/${n}: credential readable, login valid.`);
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
- line(OK, `claude/${n}: ${health.length} shared link(s) intact.`);
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
- line(WARN, `claude/${n}: ${parts.join("; ")} — run \`agent-switch share sync\`.`);
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
- // 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.`);
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
- // Ecosystem tooling (agent-config + rtk) — same detection module as
138
- // `agent-switch tooling`, never a second implementation. Both tools are
139
- // optional, so an unhealthy row is a WARN, never a blocking error.
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
- line(t.healthy ? OK : WARN, doctorToolingLine(t));
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
- line(hardError ? ERR : OK, hardError ? "doctor found a blocking problem (see above)." : "no blocking problems.");
146
- return hardError ? 1 : 0;
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
  }