@event4u/agent-switch 2.1.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/service.js CHANGED
@@ -68,21 +68,36 @@ export function schtasksCreateArgs(exec, args, label = LABEL) {
68
68
  return ["/create", "/tn", label, "/tr", cmd, "/sc", "onlogon", "/f"];
69
69
  }
70
70
  // ---------- manifest ----------
71
- function readManifest() {
71
+ function readManifestRaw() {
72
72
  try {
73
73
  const d = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
74
- return Array.isArray(d?.files) ? d.files : [];
74
+ const inv = d?.invocation;
75
+ const invocation = inv && typeof inv.exec === "string" && Array.isArray(inv.args) ? { exec: inv.exec, args: inv.args.map(String) } : null;
76
+ return { files: Array.isArray(d?.files) ? d.files : [], invocation };
75
77
  }
76
78
  catch {
77
- return [];
79
+ return { files: [], invocation: null };
78
80
  }
79
81
  }
82
+ function readManifest() {
83
+ return readManifestRaw().files;
84
+ }
80
85
  function writeManifest(files) {
81
86
  fs.mkdirSync(ROOT, { recursive: true });
82
- fs.writeFileSync(MANIFEST, JSON.stringify({ label: LABEL, files }, null, 2) + "\n", { mode: 0o600 });
87
+ // The invocation is what `activate` compares against to re-point the service.
88
+ fs.writeFileSync(MANIFEST, JSON.stringify({ label: LABEL, files, invocation: runInvocation() }, null, 2) + "\n", { mode: 0o600 });
89
+ }
90
+ /** Whether the installed service runs this very CLI. An install from before the
91
+ * manifest recorded its invocation counts as another one. */
92
+ export function serviceTarget() {
93
+ const m = readManifestRaw();
94
+ if (!m.files.length)
95
+ return "not-installed";
96
+ const me = runInvocation();
97
+ return m.invocation && m.invocation.exec === me.exec && m.invocation.args.join("\0") === me.args.join("\0") ? "this-cli" : "other-cli";
83
98
  }
84
99
  // ---------- install / uninstall ----------
85
- export function serviceInstall() {
100
+ export function serviceInstall(log = console.log) {
86
101
  const { exec, args } = runInvocation();
87
102
  fs.mkdirSync(ROOT, { recursive: true });
88
103
  if (process.platform === "darwin") {
@@ -94,7 +109,7 @@ export function serviceInstall() {
94
109
  if (r.status !== 0)
95
110
  die(`launchctl load failed: ${r.stderr || r.stdout}`);
96
111
  writeManifest([plist]);
97
- console.log(`Installed LaunchAgent ${plist} (loaded).`);
112
+ log(`Installed LaunchAgent ${plist} (loaded).`);
98
113
  }
99
114
  else if (process.platform === "linux") {
100
115
  const unit = path.join(os.homedir(), ".config", "systemd", "user", "agent-switch.service");
@@ -103,16 +118,16 @@ export function serviceInstall() {
103
118
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
104
119
  const r = spawnSync("systemctl", ["--user", "enable", "--now", "agent-switch.service"], { encoding: "utf8" });
105
120
  if (r.status !== 0)
106
- console.log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
121
+ log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
107
122
  writeManifest([unit]);
108
- console.log(`Installed systemd user unit ${unit}.`);
123
+ log(`Installed systemd user unit ${unit}.`);
109
124
  }
110
125
  else if (process.platform === "win32") {
111
126
  const r = spawnSync("schtasks", schtasksCreateArgs(exec, args), { encoding: "utf8" });
112
127
  if (r.status !== 0)
113
128
  die(`schtasks /create failed: ${r.stderr || r.stdout}`);
114
129
  writeManifest([`schtasks:${LABEL}`]); // a task, not a file
115
- console.log(`Installed Task Scheduler logon task ${LABEL}.`);
130
+ log(`Installed Task Scheduler logon task ${LABEL}.`);
116
131
  }
117
132
  else {
118
133
  die(`service install is not supported on ${process.platform}`);
package/dist/sessions.js CHANGED
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Iron rules (roadmap: road-to-session-handoff):
12
12
  * - Transcripts are OPAQUE, version-unstable blobs FOR TRANSFER. The only
13
- * read in THIS module is `readSessionHeader` — first line, capped,
13
+ * read in THIS module is `readSessionHeader` — first 64 KiB, capped,
14
14
  * try/catch. There are exactly TWO sanctioned body readers, both guarded +
15
15
  * capped + fenced + degraded-mode, and NO others: read-only TELEMETRY
16
16
  * (context/token counts, `src/telemetry.ts`, road-to-agent-switch-session-
@@ -33,9 +33,9 @@ import { liveSessionPids } from "./api.js";
33
33
  export function encodeProjectDir(cwd) {
34
34
  return cwd.replace(/[^A-Za-z0-9]/g, "-");
35
35
  }
36
- /** Best-effort metadata from a transcript's FIRST line only. The line is
37
- * capped (a >64 KiB first line counts as unparseable), the parse is fenced,
38
- * and absence degrades to nulls — the format is internal and version-unstable,
36
+ /** Best-effort metadata (cwd, summary) from the complete lines in a
37
+ * transcript's first 64 KiB. Each parse is fenced, a line cut by the cap is
38
+ * ignored, and absence degrades to nulls — the format is internal and version-unstable,
39
39
  * so a failed parse must never fail a listing. */
40
40
  const HEADER_CAP = 64 * 1024;
41
41
  export function readSessionHeader(file) {
@@ -51,15 +51,30 @@ export function readSessionHeader(file) {
51
51
  const buf = Buffer.alloc(HEADER_CAP);
52
52
  const n = fs.readSync(fd, buf, 0, HEADER_CAP, 0);
53
53
  const chunk = buf.subarray(0, n).toString("utf8");
54
- const nl = chunk.indexOf("\n");
55
- if (nl < 0 && n === HEADER_CAP)
56
- return none; // first line larger than the cap
57
- const line = nl >= 0 ? chunk.slice(0, nl) : chunk;
58
- const parsed = JSON.parse(line);
59
- return {
60
- cwd: typeof parsed?.cwd === "string" ? parsed.cwd : null,
61
- summary: typeof parsed?.summary === "string" ? parsed.summary : null,
62
- };
54
+ // Claude Code 2.1.x opens with metadata lines that carry no cwd, so scan the
55
+ // complete lines inside the cap. A trailing partial line (cut by the cap) is
56
+ // dropped rather than guessed at.
57
+ const lines = chunk.split("\n");
58
+ if (n === HEADER_CAP)
59
+ lines.pop();
60
+ let cwd = null;
61
+ let summary = null;
62
+ for (const line of lines) {
63
+ if (cwd !== null && summary !== null)
64
+ break;
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(line);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ if (cwd === null && typeof parsed?.cwd === "string")
73
+ cwd = parsed.cwd;
74
+ if (summary === null && typeof parsed?.summary === "string")
75
+ summary = parsed.summary;
76
+ }
77
+ return { cwd, summary };
63
78
  }
64
79
  catch {
65
80
  return none;
@@ -68,6 +83,60 @@ export function readSessionHeader(file) {
68
83
  fs.closeSync(fd);
69
84
  }
70
85
  }
86
+ /** Tools that always stop for the user's answer. Other tools can't be told
87
+ * apart from one that is still running. */
88
+ const QUESTION_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
89
+ const TAIL_CAP = 64 * 1024;
90
+ /** Whether the transcript ends on an open question to the user. */
91
+ export function awaitingAnswer(file) {
92
+ return openQuestion(file) !== null;
93
+ }
94
+ /**
95
+ * The id of the question the transcript ends on — an AskUserQuestion /
96
+ * ExitPlanMode call with no tool_result after it — or null. The id tells one
97
+ * question from the next, so two in a row are two moments. Reads only the
98
+ * last {@link TAIL_CAP} bytes.
99
+ */
100
+ export function openQuestion(file) {
101
+ let text;
102
+ try {
103
+ const fd = fs.openSync(file, "r");
104
+ try {
105
+ const size = fs.fstatSync(fd).size;
106
+ const len = Math.min(size, TAIL_CAP);
107
+ const buf = Buffer.alloc(len);
108
+ fs.readSync(fd, buf, 0, len, size - len);
109
+ text = buf.toString("utf8");
110
+ }
111
+ finally {
112
+ fs.closeSync(fd);
113
+ }
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ const open = new Set();
119
+ // The first line may be cut off by the tail window; it fails to parse and is skipped.
120
+ for (const line of text.split("\n")) {
121
+ let d;
122
+ try {
123
+ d = JSON.parse(line);
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ const content = d?.message?.content;
129
+ if (!Array.isArray(content))
130
+ continue;
131
+ for (const p of content) {
132
+ if (p?.type === "tool_use" && p.id && p.name && QUESTION_TOOLS.has(p.name))
133
+ open.add(p.id);
134
+ else if (p?.type === "tool_result" && p.tool_use_id)
135
+ open.delete(p.tool_use_id);
136
+ }
137
+ }
138
+ return open.size ? [...open][open.size - 1] : null;
139
+ }
71
140
  /** Recent sessions of one profile config dir, newest first, capped at `limit`.
72
141
  * Pure directory scan — works on seeded fakes, shared trees, and win32. */
73
142
  export function listSessions(configDir, limit) {
package/dist/share.js CHANGED
@@ -15,9 +15,15 @@
15
15
  * re-links (last-sync-wins across profiles). On win32, file symlinks need
16
16
  * Developer Mode/admin, so file links degrade with a message there.
17
17
  *
18
+ * - Prompt history (`history.jsonl`, what ↑ and Ctrl+R search) — always
19
+ * linked: it holds no account state, and Claude Code only ever APPENDS to
20
+ * it (under a lock), so the link never forks. A profile's own file is
21
+ * merged into the shared one (chronological, de-duplicated) and kept as a
22
+ * `history.jsonl.pre-share*` backup instead of blocking the link.
23
+ *
18
24
  * Account-/instance-scoped items (`.claude.json`, `.credentials.json`,
19
25
  * `plugins/`, `sessions/`, `ide/`, `statsig/`) are deliberately never shared.
20
- * History (`projects/`, `history.jsonl`, `plans/`) is a POSIX-only opt-in.
26
+ * Conversations (`projects/`, `plans/`) are a POSIX-only opt-in (`--history`).
21
27
  * (`plans/` defaults to `<CLAUDE_CONFIG_DIR>/plans/` in Claude Code, so plan-mode
22
28
  * files are otherwise isolated per profile — verified against claude-code 2.1.215.)
23
29
  *
@@ -34,18 +40,21 @@ const BASE_ITEMS = [
34
40
  { name: "commands", kind: "dir" },
35
41
  { name: "agents", kind: "dir" },
36
42
  ];
37
- /** Conversation history + plan-mode files; opt-in — shares `claude --resume`
43
+ /** Append-only logs shared by every `share on`, but never part of a config
44
+ * export (`SHARED_ITEMS` is that export's allowlist). */
45
+ const LOG_ITEMS = [{ name: "history.jsonl", kind: "log" }];
46
+ /** Conversations + plan-mode files; opt-in — shares `claude --resume`
38
47
  * and the plan store across accounts. `plans/` is a `dir`, so writes inside it
39
48
  * reach the source (no atomic-rename fork), exactly like `projects/`. */
40
49
  const HISTORY_ITEMS = [
41
50
  { name: "projects", kind: "dir" },
42
- { name: "history.jsonl", kind: "file" },
43
51
  { name: "plans", kind: "dir" },
44
52
  ];
45
53
  /** Names exported for messaging/tests. */
46
54
  export const SHARED_ITEMS = BASE_ITEMS.map((i) => i.name);
47
- export const HISTORY_ITEM_NAMES = HISTORY_ITEMS.map((i) => i.name);
48
- const ITEM_KIND = new Map([...BASE_ITEMS, ...HISTORY_ITEMS].map((i) => [i.name, i.kind]));
55
+ export const PROMPT_HISTORY = LOG_ITEMS[0].name;
56
+ export const HISTORY_ITEM_NAMES = [...HISTORY_ITEMS, ...LOG_ITEMS].map((i) => i.name);
57
+ const ITEM_KIND = new Map([...BASE_ITEMS, ...LOG_ITEMS, ...HISTORY_ITEMS].map((i) => [i.name, i.kind]));
49
58
  const MANIFEST = ".agent-switch-shared.json";
50
59
  function readManifest(dir) {
51
60
  try {
@@ -72,6 +81,59 @@ function link(src, dst, kind) {
72
81
  fs.symlinkSync(src, dst, process.platform === "win32" ? "file" : undefined);
73
82
  }
74
83
  }
84
+ const stampOf = (l) => {
85
+ try {
86
+ const t = JSON.parse(l)?.timestamp;
87
+ return typeof t === "number" && Number.isFinite(t) ? t : null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ };
93
+ /**
94
+ * Merge the lines of `extra` into the prompt history at `src`: de-duplicated by
95
+ * exact line, ordered by timestamp (Claude Code reads the file newest-last, so
96
+ * ↑ walks back in time). Unparseable lines keep their place after their
97
+ * predecessor. Written via temp + rename; re-read when the source grew while we
98
+ * merged, so a concurrent append is not lost. Returns the lines added.
99
+ */
100
+ export function mergePromptHistory(src, extra) {
101
+ const linesOf = (f) => fs.existsSync(f) ? fs.readFileSync(f, "utf8").split("\n").filter((l) => l.trim() !== "") : [];
102
+ const incoming = linesOf(extra);
103
+ for (let attempt = 0;; attempt++) {
104
+ const before = fs.existsSync(src) ? fs.statSync(src).size : -1;
105
+ const own = linesOf(src);
106
+ const seen = new Set(own);
107
+ const added = incoming.filter((l) => !seen.has(l) && (seen.add(l), true));
108
+ if (added.length === 0)
109
+ return 0;
110
+ let last = 0;
111
+ const keyed = [...own, ...added].map((l, i) => {
112
+ last = stampOf(l) ?? last;
113
+ return { l, i, t: last };
114
+ });
115
+ keyed.sort((a, b) => a.t - b.t || a.i - b.i);
116
+ const tmp = `${src}.agent-switch-merge`;
117
+ fs.writeFileSync(tmp, keyed.map((k) => k.l).join("\n") + "\n", { mode: 0o600 });
118
+ const after = fs.existsSync(src) ? fs.statSync(src).size : -1;
119
+ if (after !== before && attempt < 3) {
120
+ fs.unlinkSync(tmp);
121
+ continue;
122
+ }
123
+ fs.renameSync(tmp, src);
124
+ return added.length;
125
+ }
126
+ }
127
+ /** Merge a profile's own prompt history into the shared one, keep the original
128
+ * as a backup next to it, and leave the slot free for the link. */
129
+ function adoptLog(src, dst, name) {
130
+ const n = mergePromptHistory(src, dst);
131
+ let backup = `${dst}.pre-share`;
132
+ if (fs.existsSync(backup))
133
+ backup = `${backup}-${Date.now()}`;
134
+ fs.renameSync(dst, backup);
135
+ return `merged ${n} prompt(s) from ${name} (original kept as ${path.basename(backup)})`;
136
+ }
75
137
  /** True when the existing symlink already points at src (junction paths on
76
138
  * win32 may carry a trailing separator / `\\?\` prefix, so compare loosely). */
77
139
  function pointsAt(dst, src) {
@@ -108,14 +170,12 @@ export function settingsSharingRisk(settingsPath) {
108
170
  /** Link items from sourceDir into targetDir. Returns human-readable actions. */
109
171
  export function applySharing(sourceDir, targetDir, withHistory) {
110
172
  const actions = [];
111
- const items = withHistory ? [...BASE_ITEMS, ...HISTORY_ITEMS] : [...BASE_ITEMS];
173
+ const items = withHistory ? [...BASE_ITEMS, ...LOG_ITEMS, ...HISTORY_ITEMS] : [...BASE_ITEMS, ...LOG_ITEMS];
112
174
  const managed = new Set(readManifest(targetDir));
113
175
  fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
114
176
  for (const item of items) {
115
177
  const src = path.join(sourceDir, item.name);
116
178
  const dst = path.join(targetDir, item.name);
117
- if (!fs.existsSync(src))
118
- continue;
119
179
  let st = null;
120
180
  try {
121
181
  st = fs.lstatSync(dst);
@@ -123,6 +183,12 @@ export function applySharing(sourceDir, targetDir, withHistory) {
123
183
  catch {
124
184
  /* absent */
125
185
  }
186
+ // The shared prompt history starts empty when the source never had one.
187
+ if (item.kind === "log" && !fs.existsSync(src) && process.platform !== "win32") {
188
+ fs.writeFileSync(src, "", { mode: 0o600 });
189
+ }
190
+ if (!fs.existsSync(src))
191
+ continue;
126
192
  if (st?.isSymbolicLink()) {
127
193
  if (pointsAt(dst, src)) {
128
194
  managed.add(item.name);
@@ -136,14 +202,19 @@ export function applySharing(sourceDir, targetDir, withHistory) {
136
202
  }
137
203
  else if (st) {
138
204
  // A regular file/dir sits where a link would go.
139
- if (item.kind === "file" && managed.has(item.name)) {
205
+ if (item.kind === "log" && st.isFile() && process.platform !== "win32") {
206
+ actions.push(adoptLog(src, dst, item.name));
207
+ }
208
+ else if (item.kind === "file" && managed.has(item.name)) {
140
209
  // We linked it before; it's now a regular file → an in-profile write
141
210
  // forked it. Don't clobber — `share sync` reconciles it.
142
211
  actions.push(`forked ${item.name}: run 'agent-switch share sync' to re-link`);
143
212
  continue;
144
213
  }
145
- actions.push(`skipped ${item.name}: profile has its own copy (remove it to share)`);
146
- continue;
214
+ else {
215
+ actions.push(`skipped ${item.name}: profile has its own copy (remove it to share)`);
216
+ continue;
217
+ }
147
218
  }
148
219
  if (item.name === "settings.json") {
149
220
  const risk = settingsSharingRisk(src);
@@ -158,7 +229,7 @@ export function applySharing(sourceDir, targetDir, withHistory) {
158
229
  actions.push(`linked ${item.name}`);
159
230
  }
160
231
  catch (err) {
161
- if (process.platform === "win32" && item.kind === "file") {
232
+ if (process.platform === "win32" && item.kind !== "dir") {
162
233
  actions.push(`skipped ${item.name}: Windows file symlinks need Developer Mode or admin ` +
163
234
  `(directories are shared via junction regardless)`);
164
235
  }
@@ -199,7 +270,11 @@ export function syncSharing(sourceDir, targetDir) {
199
270
  }
200
271
  if (st.isSymbolicLink())
201
272
  continue; // intact
202
- if (st.isFile()) {
273
+ if (st.isFile() && kind === "log") {
274
+ actions.push(`${adoptLog(src, dst, name)}, re-linked`);
275
+ link(src, dst, kind);
276
+ }
277
+ else if (st.isFile()) {
203
278
  // Forked file: propagate the profile's edit to the source, then re-link.
204
279
  if (name === "settings.json") {
205
280
  const risk = settingsSharingRisk(dst);
@@ -244,7 +319,11 @@ export function removeSharing(targetDir) {
244
319
  // A symlink (POSIX) or a junction (win32, reported as a symlink by
245
320
  // lstat) is ours to remove; a forked regular file is the user's, leave it.
246
321
  if (st.isSymbolicLink()) {
322
+ const shared = ITEM_KIND.get(name) === "log" && fs.existsSync(dst) ? fs.realpathSync(dst) : null;
247
323
  fs.unlinkSync(dst);
324
+ // Unsharing must not empty a profile's prompt history: it keeps a copy.
325
+ if (shared)
326
+ fs.copyFileSync(shared, dst);
248
327
  actions.push(`unlinked ${name}`);
249
328
  }
250
329
  }
package/dist/shellenv.js CHANGED
@@ -34,6 +34,8 @@
34
34
  * A side effect worth having: a genuinely broken agent-switch now surfaces its
35
35
  * error instead of silently degrading the shell onto the default config dir.
36
36
  */
37
+ import * as fs from "node:fs";
38
+ import * as path from "node:path";
37
39
  import { allProviders } from "./providers.js";
38
40
  import { GUARD_REFUSED_EXIT } from "./single-load.js";
39
41
  export const SHELLS = ["zsh", "bash", "fish", "powershell"];
@@ -59,11 +61,56 @@ export function detectShell(requested, env = process.env, platform = process.pla
59
61
  return "zsh";
60
62
  return "zsh"; // sensible POSIX default when $SHELL is unset
61
63
  }
64
+ // The CLI the running app activated (`agent-switch activate` writes its path to
65
+ // <home>/active-cli), so a dev build and an installed app each bring their own;
66
+ // a missing or vanished path falls back to agent-switch on PATH.
67
+ const POSIX_BIN = `__agent_switch_bin() {
68
+ local p
69
+ p="$(cat "\${AGENT_SWITCH_HOME:-$HOME/.agent-switch}/active-cli" 2>/dev/null)"
70
+ if [ -n "$p" ] && [ -x "$p" ]; then printf '%s\\n' "$p"
71
+ elif command -v agent-switch >/dev/null 2>&1; then printf 'agent-switch\\n'
72
+ fi
73
+ }
74
+ __agent_switch() {
75
+ local as
76
+ as="$(__agent_switch_bin)"
77
+ command "\${as:-agent-switch}" "$@"
78
+ }`;
79
+ const FISH_BIN = `function __agent_switch_bin
80
+ set -l home $HOME/.agent-switch
81
+ set -q AGENT_SWITCH_HOME; and set home $AGENT_SWITCH_HOME
82
+ if test -r $home/active-cli
83
+ set -l p (cat $home/active-cli)
84
+ if test -x "$p"
85
+ echo $p
86
+ return
87
+ end
88
+ end
89
+ type -q agent-switch; and echo agent-switch
90
+ end
91
+ function __agent_switch
92
+ set -l as (__agent_switch_bin)
93
+ test -n "$as"; or set as agent-switch
94
+ command $as $argv
95
+ end`;
96
+ const POWERSHELL_BIN = `function __agent_switch_bin {
97
+ $home_ = if ($env:AGENT_SWITCH_HOME) { $env:AGENT_SWITCH_HOME } else { Join-Path $HOME '.agent-switch' }
98
+ $p = Get-Content -Path (Join-Path $home_ 'active-cli') -ErrorAction SilentlyContinue | Select-Object -First 1
99
+ if ($p -and (Test-Path $p)) { return $p }
100
+ if (Get-Command agent-switch -ErrorAction SilentlyContinue) { return 'agent-switch' }
101
+ return $null
102
+ }
103
+ function __agent_switch {
104
+ $as = __agent_switch_bin
105
+ if (-not $as) { $as = 'agent-switch' }
106
+ if ($as -like '*.js') { & node $as @args } else { & $as @args }
107
+ }`;
62
108
  function posixWrapper(binary, envVar, id) {
63
109
  return `${binary}() {
64
- local dir rc
65
- command -v agent-switch >/dev/null 2>&1 || { command ${binary} "$@"; return; }
66
- dir="$(command agent-switch dir --provider ${id} --guard)"
110
+ local dir rc as
111
+ as="$(__agent_switch_bin)"
112
+ [ -n "$as" ] || { command ${binary} "$@"; return; }
113
+ dir="$(command "$as" dir --provider ${id} --guard)"
67
114
  rc=$?
68
115
  if [ $rc -eq ${GUARD_REFUSED_EXIT} ]; then return $rc; fi
69
116
  if [ -n "$dir" ]; then
@@ -75,11 +122,12 @@ function posixWrapper(binary, envVar, id) {
75
122
  }
76
123
  function fishWrapper(binary, envVar, id) {
77
124
  return `function ${binary}
78
- if not type -q agent-switch
125
+ set -l as (__agent_switch_bin)
126
+ if test -z "$as"
79
127
  command ${binary} $argv
80
128
  return
81
129
  end
82
- set -l dir (command agent-switch dir --provider ${id} --guard)
130
+ set -l dir (command $as dir --provider ${id} --guard)
83
131
  set -l rc $status
84
132
  if test $rc -eq ${GUARD_REFUSED_EXIT}
85
133
  return $rc
@@ -95,8 +143,9 @@ function powershellWrapper(binary, envVar, id) {
95
143
  return `function ${binary} {
96
144
  $exe = Get-Command ${binary} -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
97
145
  if (-not $exe) { Write-Error '${binary} not found on PATH.'; return }
98
- if (-not (Get-Command agent-switch -ErrorAction SilentlyContinue)) { & $exe.Source @args; return }
99
- $dir = (& agent-switch dir --provider ${id} --guard)
146
+ $as = __agent_switch_bin
147
+ if (-not $as) { & $exe.Source @args; return }
148
+ $dir = (__agent_switch dir --provider ${id} --guard)
100
149
  if ($LASTEXITCODE -eq ${GUARD_REFUSED_EXIT}) { return }
101
150
  if ($dir) {
102
151
  $prev = Get-Item -Path Env:\\${envVar} -ErrorAction SilentlyContinue
@@ -107,8 +156,8 @@ function powershellWrapper(binary, envVar, id) {
107
156
  }
108
157
  }`;
109
158
  }
110
- function build(header, wrappers, asw) {
111
- return [header, ...wrappers, asw].join("\n");
159
+ function build(header, bin, wrappers, asw) {
160
+ return [header, bin, ...wrappers, asw].join("\n");
112
161
  }
113
162
  export function shellenvScript(shell) {
114
163
  const providers = allProviders();
@@ -117,34 +166,64 @@ export function shellenvScript(shell) {
117
166
  const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
118
167
  function asw
119
168
  if test (count $argv) -eq 0
120
- command agent-switch list
169
+ __agent_switch list
121
170
  else if contains -- $argv[1] claude codex antigravity
122
- command agent-switch use $argv[2] --provider $argv[1]
171
+ __agent_switch use $argv[2] --provider $argv[1]
123
172
  else
124
- command agent-switch use $argv
173
+ __agent_switch use $argv
125
174
  end
126
175
  end`;
127
- return build(header, providers.map((p) => fishWrapper(p.binary, p.envVar, p.id)), asw);
176
+ return build(header, FISH_BIN, providers.map((p) => fishWrapper(p.binary, p.envVar, p.id)), asw);
128
177
  }
129
178
  if (shell === "powershell") {
130
179
  const header = `# agent-switch shell integration — add to $PROFILE: agent-switch shellenv --shell powershell | Out-String | Invoke-Expression`;
131
180
  const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
132
181
  function asw {
133
- if ($args.Count -eq 0) { command agent-switch list; return }
134
- if (@('claude','codex','antigravity') -contains $args[0]) { & agent-switch use $args[1] --provider $args[0] }
135
- else { & agent-switch use @args }
182
+ if ($args.Count -eq 0) { __agent_switch list; return }
183
+ if (@('claude','codex','antigravity') -contains $args[0]) { __agent_switch use $args[1] --provider $args[0] }
184
+ else { __agent_switch use @args }
136
185
  }`;
137
- return build(header, providers.map((p) => powershellWrapper(p.binary, p.envVar, p.id)), asw);
186
+ return build(header, POWERSHELL_BIN, providers.map((p) => powershellWrapper(p.binary, p.envVar, p.id)), asw);
138
187
  }
139
188
  // zsh + bash share the POSIX snippet.
140
189
  const header = `# agent-switch shell integration — add to your rc file: eval "$(agent-switch shellenv)"`;
141
190
  const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
142
191
  asw() {
143
- if [ $# -eq 0 ]; then command agent-switch list; return; fi
192
+ if [ $# -eq 0 ]; then __agent_switch list; return; fi
144
193
  case "$1" in
145
- claude|codex|antigravity) command agent-switch use "$2" --provider "$1" ;;
146
- *) command agent-switch use "$@" ;;
194
+ claude|codex|antigravity) __agent_switch use "$2" --provider "$1" ;;
195
+ *) __agent_switch use "$@" ;;
147
196
  esac
148
197
  }`;
149
- return build(header, providers.map((p) => posixWrapper(p.binary, p.envVar, p.id)), asw);
198
+ return build(header, POSIX_BIN, providers.map((p) => posixWrapper(p.binary, p.envVar, p.id)), asw);
199
+ }
200
+ /** The line that loads the integration, per shell (same as the docs table). */
201
+ export const SHELLENV_LINE = {
202
+ zsh: 'eval "$(agent-switch shellenv)"',
203
+ bash: 'eval "$(agent-switch shellenv)"',
204
+ fish: "agent-switch shellenv --shell fish | source",
205
+ powershell: "agent-switch shellenv --shell powershell | Out-String | Invoke-Expression",
206
+ };
207
+ /** The startup file `shellenv install` writes to. macOS opens login shells,
208
+ * which read ~/.bash_profile rather than ~/.bashrc. */
209
+ export function rcFileFor(shell, home, platform = process.platform) {
210
+ if (shell === "zsh")
211
+ return path.join(home, ".zshrc");
212
+ if (shell === "bash")
213
+ return path.join(home, platform === "darwin" ? ".bash_profile" : ".bashrc");
214
+ if (shell === "fish")
215
+ return path.join(home, ".config", "fish", "config.fish");
216
+ return path.join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1");
217
+ }
218
+ /** Append the loader line to the shell's startup file, once. `present` says
219
+ * whether any known startup file already loads it (the doctor check). */
220
+ export function installShellIntegration(shell, home, present) {
221
+ const file = rcFileFor(shell, home);
222
+ if (present)
223
+ return { shell, file, alreadyInstalled: true };
224
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
225
+ const sep = before === "" || before.endsWith("\n") ? "" : "\n";
226
+ fs.mkdirSync(path.dirname(file), { recursive: true });
227
+ fs.writeFileSync(file, `${before}${sep}\n# agent-switch shell integration\n${SHELLENV_LINE[shell]}\n`);
228
+ return { shell, file, alreadyInstalled: false };
150
229
  }
@@ -25,7 +25,12 @@ import * as path from "node:path";
25
25
  import { liveSessionPids } from "./api.js";
26
26
  import { pidCwd } from "./sessions.js";
27
27
  export const GUARD_POLICIES = ["block", "warn", "off"];
28
+ /** What a state file without a `guard` key meant before 2.2 — kept so an
29
+ * upgrade never changes a user's effective guard. */
28
30
  export const DEFAULT_GUARD_POLICY = "block";
31
+ /** A fresh install (no state file and no profile yet) starts on `warn`: a second load is
32
+ * reported, never refused, so nothing changes how a tool starts. */
33
+ export const FRESH_INSTALL_GUARD_POLICY = "warn";
29
34
  export function isGuardPolicy(v) {
30
35
  return typeof v === "string" && GUARD_POLICIES.includes(v);
31
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-switch",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Switch between multiple Claude Code accounts on macOS, Linux, and Windows via isolated CLAUDE_CONFIG_DIR profiles",
5
5
  "repository": {
6
6
  "type": "git",