@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/README.md +170 -68
- package/dist/activity.js +97 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +149 -0
- package/dist/balance.js +52 -0
- package/dist/browsers.js +90 -0
- package/dist/config-transfer.js +12 -2
- package/dist/daemon.js +89 -38
- package/dist/desktop-sessions.js +115 -0
- package/dist/dir-resolve.js +151 -0
- package/dist/doctor.js +209 -52
- package/dist/gui-settings.js +67 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +921 -71
- package/dist/mappings.js +48 -9
- package/dist/pools.js +144 -0
- package/dist/profiles.js +84 -5
- package/dist/protection.js +93 -0
- package/dist/providers.js +3 -0
- package/dist/rebind.js +108 -11
- package/dist/release-notes.js +154 -0
- package/dist/service.js +24 -9
- package/dist/sessions.js +101 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +131 -18
- package/dist/single-load.js +176 -0
- package/dist/tooling.js +73 -0
- package/dist/updates.js +37 -3
- package/package.json +1 -1
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
48
|
-
const
|
|
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 === "
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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
|
@@ -13,8 +13,31 @@
|
|
|
13
13
|
* `asw` convenience: bare `asw` lists all providers' profiles; `asw <name>`
|
|
14
14
|
* switches the active Claude profile; `asw <provider> <name>` switches that
|
|
15
15
|
* provider's.
|
|
16
|
+
*
|
|
17
|
+
* SINGLE-LOAD GUARD. The wrapper is the one place every interactive start
|
|
18
|
+
* funnels through, so it is where the guard has to sit. `dir --guard` keeps the
|
|
19
|
+
* config path on stdout (unchanged) and writes the human message to stderr, so
|
|
20
|
+
* one invocation both feeds the wrapper and talks to the user — no second
|
|
21
|
+
* process launch per command.
|
|
22
|
+
*
|
|
23
|
+
* The wrapper blocks on EXACTLY ONE exit code (86, GUARD_REFUSED_EXIT) and on
|
|
24
|
+
* nothing else. That asymmetry is deliberate and load-bearing: a missing,
|
|
25
|
+
* older, or broken `agent-switch` exits 127 / 1 / 2 and the wrapper carries on
|
|
26
|
+
* to the real binary. This tool must never become the reason a user cannot
|
|
27
|
+
* reach their assistant.
|
|
28
|
+
*
|
|
29
|
+
* Each wrapper first checks that `agent-switch` exists at all and otherwise
|
|
30
|
+
* calls the real binary straight away. That check replaces the old
|
|
31
|
+
* `2>/dev/null`: the guard has to be able to SPEAK on stderr, so the blanket
|
|
32
|
+
* redirect had to go, and without the existence check an uninstalled
|
|
33
|
+
* `agent-switch` would print "command not found" on every single invocation.
|
|
34
|
+
* A side effect worth having: a genuinely broken agent-switch now surfaces its
|
|
35
|
+
* error instead of silently degrading the shell onto the default config dir.
|
|
16
36
|
*/
|
|
37
|
+
import * as fs from "node:fs";
|
|
38
|
+
import * as path from "node:path";
|
|
17
39
|
import { allProviders } from "./providers.js";
|
|
40
|
+
import { GUARD_REFUSED_EXIT } from "./single-load.js";
|
|
18
41
|
export const SHELLS = ["zsh", "bash", "fish", "powershell"];
|
|
19
42
|
/** Resolve the target shell: explicit request wins, else detect from the
|
|
20
43
|
* environment / platform. Windows defaults to PowerShell; POSIX reads $SHELL. */
|
|
@@ -38,10 +61,58 @@ export function detectShell(requested, env = process.env, platform = process.pla
|
|
|
38
61
|
return "zsh";
|
|
39
62
|
return "zsh"; // sensible POSIX default when $SHELL is unset
|
|
40
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
|
+
}`;
|
|
41
108
|
function posixWrapper(binary, envVar, id) {
|
|
42
109
|
return `${binary}() {
|
|
43
|
-
local dir
|
|
44
|
-
|
|
110
|
+
local dir rc as
|
|
111
|
+
as="$(__agent_switch_bin)"
|
|
112
|
+
[ -n "$as" ] || { command ${binary} "$@"; return; }
|
|
113
|
+
dir="$(command "$as" dir --provider ${id} --guard)"
|
|
114
|
+
rc=$?
|
|
115
|
+
if [ $rc -eq ${GUARD_REFUSED_EXIT} ]; then return $rc; fi
|
|
45
116
|
if [ -n "$dir" ]; then
|
|
46
117
|
${envVar}="$dir" command ${binary} "$@"
|
|
47
118
|
else
|
|
@@ -51,7 +122,16 @@ function posixWrapper(binary, envVar, id) {
|
|
|
51
122
|
}
|
|
52
123
|
function fishWrapper(binary, envVar, id) {
|
|
53
124
|
return `function ${binary}
|
|
54
|
-
set -l
|
|
125
|
+
set -l as (__agent_switch_bin)
|
|
126
|
+
if test -z "$as"
|
|
127
|
+
command ${binary} $argv
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
set -l dir (command $as dir --provider ${id} --guard)
|
|
131
|
+
set -l rc $status
|
|
132
|
+
if test $rc -eq ${GUARD_REFUSED_EXIT}
|
|
133
|
+
return $rc
|
|
134
|
+
end
|
|
55
135
|
if test -n "$dir"
|
|
56
136
|
${envVar}=$dir command ${binary} $argv
|
|
57
137
|
else
|
|
@@ -63,7 +143,10 @@ function powershellWrapper(binary, envVar, id) {
|
|
|
63
143
|
return `function ${binary} {
|
|
64
144
|
$exe = Get-Command ${binary} -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
65
145
|
if (-not $exe) { Write-Error '${binary} not found on PATH.'; return }
|
|
66
|
-
$
|
|
146
|
+
$as = __agent_switch_bin
|
|
147
|
+
if (-not $as) { & $exe.Source @args; return }
|
|
148
|
+
$dir = (__agent_switch dir --provider ${id} --guard)
|
|
149
|
+
if ($LASTEXITCODE -eq ${GUARD_REFUSED_EXIT}) { return }
|
|
67
150
|
if ($dir) {
|
|
68
151
|
$prev = Get-Item -Path Env:\\${envVar} -ErrorAction SilentlyContinue
|
|
69
152
|
$env:${envVar} = $dir
|
|
@@ -73,8 +156,8 @@ function powershellWrapper(binary, envVar, id) {
|
|
|
73
156
|
}
|
|
74
157
|
}`;
|
|
75
158
|
}
|
|
76
|
-
function build(header, wrappers, asw) {
|
|
77
|
-
return [header, ...wrappers, asw].join("\n");
|
|
159
|
+
function build(header, bin, wrappers, asw) {
|
|
160
|
+
return [header, bin, ...wrappers, asw].join("\n");
|
|
78
161
|
}
|
|
79
162
|
export function shellenvScript(shell) {
|
|
80
163
|
const providers = allProviders();
|
|
@@ -83,34 +166,64 @@ export function shellenvScript(shell) {
|
|
|
83
166
|
const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
|
|
84
167
|
function asw
|
|
85
168
|
if test (count $argv) -eq 0
|
|
86
|
-
|
|
169
|
+
__agent_switch list
|
|
87
170
|
else if contains -- $argv[1] claude codex antigravity
|
|
88
|
-
|
|
171
|
+
__agent_switch use $argv[2] --provider $argv[1]
|
|
89
172
|
else
|
|
90
|
-
|
|
173
|
+
__agent_switch use $argv
|
|
91
174
|
end
|
|
92
175
|
end`;
|
|
93
|
-
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);
|
|
94
177
|
}
|
|
95
178
|
if (shell === "powershell") {
|
|
96
179
|
const header = `# agent-switch shell integration — add to $PROFILE: agent-switch shellenv --shell powershell | Out-String | Invoke-Expression`;
|
|
97
180
|
const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
|
|
98
181
|
function asw {
|
|
99
|
-
if ($args.Count -eq 0) {
|
|
100
|
-
if (@('claude','codex','antigravity') -contains $args[0]) {
|
|
101
|
-
else {
|
|
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 }
|
|
102
185
|
}`;
|
|
103
|
-
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);
|
|
104
187
|
}
|
|
105
188
|
// zsh + bash share the POSIX snippet.
|
|
106
189
|
const header = `# agent-switch shell integration — add to your rc file: eval "$(agent-switch shellenv)"`;
|
|
107
190
|
const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
|
|
108
191
|
asw() {
|
|
109
|
-
if [ $# -eq 0 ]; then
|
|
192
|
+
if [ $# -eq 0 ]; then __agent_switch list; return; fi
|
|
110
193
|
case "$1" in
|
|
111
|
-
claude|codex|antigravity)
|
|
112
|
-
*)
|
|
194
|
+
claude|codex|antigravity) __agent_switch use "$2" --provider "$1" ;;
|
|
195
|
+
*) __agent_switch use "$@" ;;
|
|
113
196
|
esac
|
|
114
197
|
}`;
|
|
115
|
-
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 };
|
|
116
229
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-load guard: one profile, one live session.
|
|
3
|
+
*
|
|
4
|
+
* The problem it solves: `agent-switch use` writes a GLOBAL active pointer and
|
|
5
|
+
* the shell wrapper re-resolves it on every invocation, so nothing stops two
|
|
6
|
+
* terminals from starting `claude` on the same profile. Two live sessions on
|
|
7
|
+
* one account share its rate-limit windows and both refresh one rotating OAuth
|
|
8
|
+
* store (the rotation `src/locks.ts` documents) — and, worst of the three, the
|
|
9
|
+
* user believes the second terminal is on a different account because they
|
|
10
|
+
* typed `asw <other>` there.
|
|
11
|
+
*
|
|
12
|
+
* Ground truth is Claude Code's own bookkeeping, never ours: a live session
|
|
13
|
+
* writes `<config>/sessions/<pid>.json`, and `liveSessionPids` filters dead
|
|
14
|
+
* pids on every read. That makes the signal self-healing — a crashed session
|
|
15
|
+
* leaves a stale file that is simply never counted, so this module needs no
|
|
16
|
+
* cleanup job and holds no lease of its own. (An agent-switch-owned lease would
|
|
17
|
+
* need a holder process; the shell wrapper execs `claude` and exits, so every
|
|
18
|
+
* crash would leak one.)
|
|
19
|
+
*
|
|
20
|
+
* Everything here is pure — processes, environment, and clock are injected — so
|
|
21
|
+
* every branch is unit-tested without spawning anything.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { liveSessionPids } from "./api.js";
|
|
26
|
+
import { pidCwd } from "./sessions.js";
|
|
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. */
|
|
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";
|
|
34
|
+
export function isGuardPolicy(v) {
|
|
35
|
+
return typeof v === "string" && GUARD_POLICIES.includes(v);
|
|
36
|
+
}
|
|
37
|
+
/** Env var that waives the guard for exactly one invocation. An env var, not a
|
|
38
|
+
* flag: the shell wrapper passes every unknown flag THROUGH to the provider
|
|
39
|
+
* binary, so a `--force` there would reach `claude`, not us. */
|
|
40
|
+
export const OVERRIDE_ENV = "AGENT_SWITCH_ALLOW_DUPLICATE";
|
|
41
|
+
/** Exit code the wrapper keys on. Distinct from every generic failure (1, 2,
|
|
42
|
+
* 127 …) so the wrapper blocks on a REFUSAL and never on a missing or older
|
|
43
|
+
* `agent-switch` — fail-open is deliberate: this tool must never be the reason
|
|
44
|
+
* a user cannot start their assistant. */
|
|
45
|
+
export const GUARD_REFUSED_EXIT = 86;
|
|
46
|
+
function pidFileMtime(configDir, pid) {
|
|
47
|
+
try {
|
|
48
|
+
return fs.statSync(path.join(configDir, "sessions", `${pid}.json`)).mtimeMs;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Who currently holds `configDir`, and whether we are a child of one of them.
|
|
56
|
+
*
|
|
57
|
+
* `envVar` is the provider's isolation variable (`CLAUDE_CONFIG_DIR`, …). The
|
|
58
|
+
* nested comparison is on the RAW string, deliberately unresolved: that same
|
|
59
|
+
* unresolved string is what Claude Code hashes into its keychain service name
|
|
60
|
+
* (`profiles.ts` § configDir), so resolving it here would compare a different
|
|
61
|
+
* identity than the one the rest of the tool uses.
|
|
62
|
+
*/
|
|
63
|
+
export function inspectLoad(configDir, envVar, deps = {}) {
|
|
64
|
+
const livePidsOf = deps.livePidsOf ?? liveSessionPids;
|
|
65
|
+
const cwdOf = deps.cwdOf ?? pidCwd;
|
|
66
|
+
const startedMsOf = deps.startedMsOf ?? pidFileMtime;
|
|
67
|
+
const env = deps.env ?? process.env;
|
|
68
|
+
const nested = env[envVar] === configDir;
|
|
69
|
+
const pids = livePidsOf(configDir);
|
|
70
|
+
// A nested start is allowed unconditionally, so its message is never
|
|
71
|
+
// rendered — and resolving a cwd costs an `lsof` per pid on darwin. Skip the
|
|
72
|
+
// lookup rather than pay it on a path that cannot use the answer.
|
|
73
|
+
const sessions = pids.map((pid) => ({
|
|
74
|
+
pid,
|
|
75
|
+
cwd: nested ? null : cwdOf(pid),
|
|
76
|
+
startedMs: startedMsOf(configDir, pid),
|
|
77
|
+
}));
|
|
78
|
+
return { sessions, loaded: sessions.length > 0, nested };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Pure policy resolution. Order matters and each precedence is load-bearing:
|
|
82
|
+
* an undetectable provider can never refuse (no evidence), a nested start is
|
|
83
|
+
* a deliberate child rather than a duplicate, and an explicit override outranks
|
|
84
|
+
* the policy because the user just stated an intent this turn.
|
|
85
|
+
*/
|
|
86
|
+
export function guardVerdict({ policy, inspection, overridden = false, detectable = true }) {
|
|
87
|
+
if (!detectable)
|
|
88
|
+
return "allow";
|
|
89
|
+
if (policy === "off")
|
|
90
|
+
return "allow";
|
|
91
|
+
if (!inspection.loaded)
|
|
92
|
+
return "allow";
|
|
93
|
+
if (inspection.nested)
|
|
94
|
+
return "allow";
|
|
95
|
+
if (overridden)
|
|
96
|
+
return "warn"; // proceed, but still say what is happening
|
|
97
|
+
return policy === "warn" ? "warn" : "refuse";
|
|
98
|
+
}
|
|
99
|
+
/** True when the one-shot override env var is set to a meaningful value. */
|
|
100
|
+
export function overrideFromEnv(env = process.env) {
|
|
101
|
+
const v = env[OVERRIDE_ENV];
|
|
102
|
+
return typeof v === "string" && v !== "" && v !== "0" && v.toLowerCase() !== "false";
|
|
103
|
+
}
|
|
104
|
+
/** Relative age like "3m" / "2h" — mirrors the sessions listing's `ageOf`. */
|
|
105
|
+
function ageOf(startedMs, now) {
|
|
106
|
+
if (startedMs === null)
|
|
107
|
+
return null;
|
|
108
|
+
const s = Math.max(0, Math.round((now - startedMs) / 1000));
|
|
109
|
+
if (s < 60)
|
|
110
|
+
return `${s}s`;
|
|
111
|
+
if (s < 3600)
|
|
112
|
+
return `${Math.round(s / 60)}m`;
|
|
113
|
+
if (s < 86400)
|
|
114
|
+
return `${Math.round(s / 3600)}h`;
|
|
115
|
+
return `${Math.round(s / 86400)}d`;
|
|
116
|
+
}
|
|
117
|
+
/** Render actions as a table whose column width comes from the widest action,
|
|
118
|
+
* so a long override line can never collide with its own explanation. */
|
|
119
|
+
function actionTable(rows) {
|
|
120
|
+
const width = Math.max(...rows.map(([action]) => action.length));
|
|
121
|
+
return rows.map(([action, explanation]) => ` ${action.padEnd(width + 2)}${explanation}`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The one place the guard speaks. Every enforcement point renders through this
|
|
125
|
+
* so the CLI, the wrapper path, and `use` never drift into three dialects.
|
|
126
|
+
*
|
|
127
|
+
* A refusal that only says "no" hands the problem straight back, so the message
|
|
128
|
+
* names WHO holds the profile (pid, age, directory), WHY that matters, and the
|
|
129
|
+
* concrete ways forward — including the names of the profiles that are actually
|
|
130
|
+
* free, and how to switch the guard off.
|
|
131
|
+
*/
|
|
132
|
+
export function renderGuardMessage(input) {
|
|
133
|
+
const { profile, binary, verdict, inspection, offerForceFlag = false, alternatives = [], context = "start", now = Date.now(), } = input;
|
|
134
|
+
const n = inspection.sessions.length;
|
|
135
|
+
const held = `profile "${profile}" is already loaded by ${n === 1 ? "a running" : `${n} running`} ${binary} session${n === 1 ? "" : "s"}`;
|
|
136
|
+
const headline = context === "use"
|
|
137
|
+
? verdict === "refuse"
|
|
138
|
+
? `${held} — the next \`${binary}\` started here will be refused.`
|
|
139
|
+
: `${held} — a second session will be allowed (guard: warn).`
|
|
140
|
+
: verdict === "refuse"
|
|
141
|
+
? `${held}.`
|
|
142
|
+
: `${held} — continuing anyway.`;
|
|
143
|
+
const lines = [headline, ""];
|
|
144
|
+
for (const sess of inspection.sessions) {
|
|
145
|
+
const age = ageOf(sess.startedMs, now);
|
|
146
|
+
const parts = [` pid ${sess.pid}`];
|
|
147
|
+
if (age)
|
|
148
|
+
parts.push(`started ${age} ago`);
|
|
149
|
+
if (sess.cwd)
|
|
150
|
+
parts.push(sess.cwd);
|
|
151
|
+
lines.push(parts.join(" "));
|
|
152
|
+
}
|
|
153
|
+
lines.push("", "Two sessions on one account share its rate limits, and both refresh the", "same rotating OAuth token.");
|
|
154
|
+
if (verdict === "refuse") {
|
|
155
|
+
const rows = [];
|
|
156
|
+
rows.push([
|
|
157
|
+
`agent-switch use ${alternatives.length > 0 ? alternatives[0] : "<other-profile>"}`,
|
|
158
|
+
alternatives.length > 1
|
|
159
|
+
? `switch to a free account — also free: ${alternatives.slice(1).join(", ")}`
|
|
160
|
+
: alternatives.length === 1
|
|
161
|
+
? "switch to the account that is free"
|
|
162
|
+
: "point new sessions at another account",
|
|
163
|
+
]);
|
|
164
|
+
// Both overrides do the same thing, so only one is offered: `--force` where
|
|
165
|
+
// the caller can accept a flag, the env var everywhere else. A shell wrapper
|
|
166
|
+
// passes unknown flags THROUGH to the provider binary, which is why the env
|
|
167
|
+
// var exists at all — offering both would just be two names for one action.
|
|
168
|
+
rows.push(offerForceFlag
|
|
169
|
+
? ["--force", "start it anyway, this once"]
|
|
170
|
+
: [`${OVERRIDE_ENV}=1 ${binary}`, "start it anyway, this once"]);
|
|
171
|
+
rows.push(["agent-switch guard warn", "warn instead of refusing, from now on"]);
|
|
172
|
+
rows.push(["agent-switch guard off", "turn the guard off entirely"]);
|
|
173
|
+
lines.push("", "Ways forward:", ...actionTable(rows));
|
|
174
|
+
}
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
package/dist/tooling.js
CHANGED
|
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
|
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { readBinaryPath } from "./profiles.js";
|
|
21
21
|
import { npmSearchPath } from "./updates.js";
|
|
22
|
+
import { releaseNoteBlocks, releaseNotesText } from "./release-notes.js";
|
|
22
23
|
export const TOOL_IDS = ["agent-config", "rtk", "claude", "codex", "agy"];
|
|
23
24
|
/** Which provider a probed binary belongs to (for `providers link` hints and
|
|
24
25
|
* the user-linked path lookup). */
|
|
@@ -322,3 +323,75 @@ export function doctorToolingLine(t) {
|
|
|
322
323
|
}
|
|
323
324
|
return `\`${t.id}\`: ${t.hint}`;
|
|
324
325
|
}
|
|
326
|
+
// ---------- release notes (`tooling notes <tool>`) --------------------------------
|
|
327
|
+
/**
|
|
328
|
+
* Repos whose GitHub Releases are a tool's release-note source. Only tools with
|
|
329
|
+
* a source this repo has actually verified appear here — inventing a plausible
|
|
330
|
+
* repo for the rest would be worse than the refusal `toolNotesRefusal` prints.
|
|
331
|
+
* Same two sources the GUI uses (agent-config's release check, rtk's update
|
|
332
|
+
* check), so the CLI and the GUI can never describe a release differently.
|
|
333
|
+
*/
|
|
334
|
+
export const TOOL_RELEASE_REPOS = {
|
|
335
|
+
"agent-config": "event4u-app/agent-config",
|
|
336
|
+
rtk: "rtk-ai/rtk",
|
|
337
|
+
};
|
|
338
|
+
/** The release-notes source for a tool, or null when it has none. */
|
|
339
|
+
export function toolReleaseRepo(id) {
|
|
340
|
+
return TOOL_RELEASE_REPOS[id] ?? null;
|
|
341
|
+
}
|
|
342
|
+
/** Why a tool has no readable release notes here — named, never guessed. */
|
|
343
|
+
export function toolNotesRefusal(id) {
|
|
344
|
+
if (id === "claude" || id === "codex") {
|
|
345
|
+
return (`\`${id}\` is distributed on npm and this repo has no verified GitHub release source for it, ` +
|
|
346
|
+
`so there are no notes to show rather than a guessed one.\n` +
|
|
347
|
+
` Its installed version is in \`agent-switch tooling\`; upgrade with \`agent-switch tooling upgrade ${id}\`.`);
|
|
348
|
+
}
|
|
349
|
+
return (`\`${id}\` has no known release source — the Antigravity CLI ships with the Antigravity app and publishes no releases here.\n` +
|
|
350
|
+
` Its installed version is in \`agent-switch tooling\`.`);
|
|
351
|
+
}
|
|
352
|
+
/** The header line: what is installed, what is published, and whether anything
|
|
353
|
+
* is waiting. Pure — every branch is unit-tested. */
|
|
354
|
+
export function toolNotesHeader(view) {
|
|
355
|
+
const { id, installed, latest } = view;
|
|
356
|
+
if (!installed) {
|
|
357
|
+
return `${id} — not installed · ${latest ? `latest ${latest}` : "latest unknown"}`;
|
|
358
|
+
}
|
|
359
|
+
if (!latest) {
|
|
360
|
+
return `${id} — installed ${installed} · latest unknown (offline or rate-limited)`;
|
|
361
|
+
}
|
|
362
|
+
const pending = view.releases.filter((r) => r.tag !== installed).length;
|
|
363
|
+
if (installed === latest || pending === 0)
|
|
364
|
+
return `${id} — installed ${installed} · up to date`;
|
|
365
|
+
return `${id} — installed ${installed} · latest ${latest} · ${pending} update${pending === 1 ? "" : "s"} to read`;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The full `tooling notes` rendering: header, then each release as a dated
|
|
369
|
+
* block of normalised notes plus its URL. A release whose body normalises to
|
|
370
|
+
* nothing is listed with its URL and an explicit "no notes in this release" —
|
|
371
|
+
* unlike the GUI panel, a terminal command the user typed must answer rather
|
|
372
|
+
* than print nothing. `maxLinesPerRelease` caps a long body and says how much
|
|
373
|
+
* was left out. Pure.
|
|
374
|
+
*/
|
|
375
|
+
export function formatToolNotes(view, maxLinesPerRelease = 40) {
|
|
376
|
+
const out = [toolNotesHeader(view)];
|
|
377
|
+
if (view.releases.length === 0) {
|
|
378
|
+
out.push("", "No release notes available.");
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
for (const r of view.releases) {
|
|
382
|
+
const date = /^\d{4}-\d{2}-\d{2}/.test(r.publishedAt) ? r.publishedAt.slice(0, 10) : null;
|
|
383
|
+
out.push("", `${r.name || r.tag}${date ? ` — ${date}` : ""}`);
|
|
384
|
+
const { lines, omitted } = releaseNotesText(releaseNoteBlocks(r.notes), {
|
|
385
|
+
maxLines: maxLinesPerRelease,
|
|
386
|
+
indent: " ",
|
|
387
|
+
});
|
|
388
|
+
if (lines.length === 0)
|
|
389
|
+
out.push(" (no notes in this release)");
|
|
390
|
+
else
|
|
391
|
+
out.push(...lines);
|
|
392
|
+
if (omitted > 0)
|
|
393
|
+
out.push(` … ${omitted} more line${omitted === 1 ? "" : "s"}`);
|
|
394
|
+
out.push(` ${r.url}`);
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|