@event4u/agent-switch 2.0.0 → 2.2.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/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 } 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 } 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
- import { checkAuth } from "./api.js";
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,32 @@ 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
+ export async function collectDoctorReport() {
214
+ const checks = [];
215
+ checks.push(cliOnPathCheck());
216
+ checks.push(shellIntegrationCheck());
217
+ checks.push(autostartCheck(readGuiSettings()));
218
+ checks.push(apiKeyOverrideCheck());
219
+ checks.push(antigravityOsCheck());
220
+ await providerChecks(checks);
221
+ const identities = allProviders().flatMap((p) => listProfiles(p.id).map((n) => ({ provider: p.id, name: n, identity: identity(p.id, n) })));
222
+ checks.push(signInIsolationCheck(identities));
101
223
  // Share-link health (Claude-config sharing).
102
224
  for (const n of listProfiles("claude")) {
103
225
  const health = sharedLinkHealth(configDir("claude", n));
@@ -106,21 +228,56 @@ export async function runDoctor() {
106
228
  const forked = health.filter((h) => h.state === "forked").map((h) => h.name);
107
229
  const missing = health.filter((h) => h.state === "missing").map((h) => h.name);
108
230
  if (forked.length === 0 && missing.length === 0) {
109
- line(OK, `claude/${n}: ${health.length} shared link(s) intact.`);
231
+ checks.push({ id: `share-${n}`, status: "ok", title: `claude/${n}: ${health.length} shared link(s) intact`, detail: "" });
110
232
  }
111
233
  else {
112
234
  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\`.`);
235
+ checks.push({ id: `share-${n}`, status: "warn", title: `claude/${n}: shared links drifted`, detail: parts.join("; "), fix: "agent-switch share sync" });
236
+ }
237
+ }
238
+ // Single-load guard: the policy, and whether any profile is doubled RIGHT NOW.
239
+ const policy = readGuard();
240
+ checks.push({ id: "guard", status: policy === "off" ? "warn" : "ok", title: `Single-load guard: ${policy}`, detail: policy === "off" ? "A profile can be loaded twice." : "" });
241
+ const doubled = [];
242
+ for (const p of allProviders()) {
243
+ if (!p.hasLiveSessionSignal)
244
+ continue;
245
+ for (const n of listProfiles(p.id)) {
246
+ const count = liveSessionPids(configDir(p.id, n)).length;
247
+ if (count > 1)
248
+ doubled.push(`${p.id}/${n} (${count} sessions)`);
114
249
  }
115
250
  }
116
- // Ecosystem tooling (agent-config + rtk) — same detection module as
117
- // `agent-switch tooling`, never a second implementation. Both tools are
118
- // optional, so an unhealthy row is a WARN, never a blocking error.
251
+ if (doubled.length > 0) {
252
+ 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.` });
253
+ }
254
+ // Ecosystem tooling (agent-config + rtk) — both optional, so never an error.
119
255
  for (const t of checkTooling({ ids: ["agent-config", "rtk"] })) {
120
- line(t.healthy ? OK : WARN, doctorToolingLine(t));
256
+ checks.push({ id: `tooling-${t.id}`, status: t.healthy ? "ok" : "warn", title: doctorToolingLine(t), detail: "" });
257
+ }
258
+ const readiness = checks.some((c) => c.status === "error") ? "blocked" : checks.some((c) => c.status === "warn") ? "attention" : "ready";
259
+ return { readiness, platform: process.platform, checks };
260
+ }
261
+ const MARK = { ok: "✅", warn: "⚠️", error: "❌", info: "ℹ️" };
262
+ export async function runDoctor(json = false) {
263
+ const report = await collectDoctorReport();
264
+ if (json) {
265
+ console.log(JSON.stringify(report, null, 2));
266
+ return report.readiness === "blocked" ? 1 : 0;
267
+ }
268
+ console.log(`agent-switch doctor — platform ${process.platform}, node ${process.version}`);
269
+ console.log(process.platform === "darwin"
270
+ ? " claude credential store: macOS Keychain (hashed per config dir), file fallback"
271
+ : " claude credential store: plaintext .credentials.json per profile");
272
+ console.log("");
273
+ for (const c of report.checks) {
274
+ console.log(`${MARK[c.status]} ${c.title}${c.detail ? ` — ${c.detail}` : ""}`);
275
+ if (c.fix)
276
+ console.log(` fix: ${c.fix}`);
121
277
  }
122
278
  console.log(`\n profile root: ${ROOT}`);
123
279
  console.log("");
124
- line(hardError ? ERR : OK, hardError ? "doctor found a blocking problem (see above)." : "no blocking problems.");
125
- return hardError ? 1 : 0;
280
+ const blocked = report.readiness === "blocked";
281
+ console.log(`${blocked ? MARK.error : MARK.ok} ${blocked ? "doctor found a blocking problem (see above)." : "no blocking problems."}`);
282
+ return blocked ? 1 : 0;
126
283
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * GUI preferences that must outlive the webview's `localStorage` — the Pet
3
+ * intro once-flag (D36) and the launch-at-login preference. The GUI can only
4
+ * reach the filesystem through this CLI, so it reads and writes them with
5
+ * `agent-switch gui-settings`; `doctor` reads the autostart preference from the
6
+ * same file.
7
+ *
8
+ * Additive file (`<ROOT>/gui-settings.json`): absent keys mean "never decided",
9
+ * so an upgrade starts with nothing stored and the GUI migrates its old
10
+ * `localStorage` flags in once.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { ROOT, ensureRoot } from "./profiles.js";
15
+ export const GUI_SETTINGS_FILE = path.join(ROOT, "gui-settings.json");
16
+ function normalizeAutostart(raw) {
17
+ const r = (raw ?? {});
18
+ const at = typeof r.at === "string" ? r.at : "";
19
+ if (r.state === "enabled" && (r.by === "first-run" || r.by === "user"))
20
+ return { state: "enabled", by: r.by, at };
21
+ if (r.state === "disabled" && r.by === "user")
22
+ return { state: "disabled", by: "user", at };
23
+ return { state: "unset" };
24
+ }
25
+ export function readGuiSettings(file = GUI_SETTINGS_FILE) {
26
+ let raw = {};
27
+ try {
28
+ raw = JSON.parse(fs.readFileSync(file, "utf8")) ?? {};
29
+ }
30
+ catch {
31
+ /* absent / unparsable → nothing decided */
32
+ }
33
+ const d = raw.autostartDegraded;
34
+ return {
35
+ petWelcomed: raw.petWelcomed === true,
36
+ autostart: normalizeAutostart(raw.autostart),
37
+ autostartDegraded: d && typeof d === "object" && typeof d.reason === "string"
38
+ ? { at: typeof d.at === "string" ? d.at : "", reason: d.reason }
39
+ : null,
40
+ };
41
+ }
42
+ export function writeGuiSettings(next, file = GUI_SETTINGS_FILE) {
43
+ if (file === GUI_SETTINGS_FILE)
44
+ ensureRoot();
45
+ const tmp = `${file}.${process.pid}.tmp`;
46
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
47
+ fs.renameSync(tmp, file);
48
+ }
49
+ /**
50
+ * Apply one update. Guards the two invariants the roadmap pins: the pet intro
51
+ * flag only ever goes false → true, and a first-run default never replaces a
52
+ * stored choice (only `by: "user"` may overwrite one).
53
+ */
54
+ export function applyGuiSetting(cur, u, now = new Date()) {
55
+ const at = now.toISOString();
56
+ if (u.key === "pet-welcomed")
57
+ return { ...cur, petWelcomed: true };
58
+ if (u.key === "autostart-degraded") {
59
+ return { ...cur, autostartDegraded: u.reason ? { at, reason: u.reason } : null };
60
+ }
61
+ if (u.state === "disabled" && u.by !== "user")
62
+ throw new Error("only the user can turn launch-at-login off");
63
+ if (u.by === "first-run" && cur.autostart.state !== "unset")
64
+ return cur;
65
+ const autostart = u.state === "enabled" ? { state: "enabled", by: u.by, at } : { state: "disabled", by: "user", at };
66
+ return { ...cur, autostart, autostartDegraded: null };
67
+ }
package/dist/hooks.js CHANGED
@@ -122,6 +122,27 @@ export function readEvents(file) {
122
122
  return [];
123
123
  }
124
124
  }
125
+ /**
126
+ * Sessions waiting on the user: their last hook event is `Notification` (a
127
+ * permission prompt, or a long wait for input) and the transcript has not been
128
+ * written since — a reply or an approval writes to it. `Stop` alone is not
129
+ * waiting: it ends every turn.
130
+ */
131
+ export function waitingSessions(events, mtimeMs) {
132
+ const last = new Map();
133
+ for (const e of events)
134
+ if (e.sessionId)
135
+ last.set(e.sessionId, e);
136
+ // sessionId → when it started waiting.
137
+ const out = new Map();
138
+ for (const [id, e] of last) {
139
+ const written = mtimeMs(id);
140
+ // Two seconds of slack: the notification and the transcript line that led to it land together.
141
+ if (e.event === "Notification" && written !== null && Date.parse(e.at) + 2000 >= written)
142
+ out.set(id, e.at);
143
+ }
144
+ return out;
145
+ }
125
146
  /** Append one event, trimming the ring to EVENT_RING_CAP. Tolerant of a
126
147
  * malformed existing file (starts fresh). */
127
148
  export function appendEvent(file, rec) {