@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/README.md +139 -70
- 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/daemon.js +89 -38
- package/dist/desktop-sessions.js +115 -0
- package/dist/dir-resolve.js +151 -0
- package/dist/doctor.js +205 -69
- package/dist/gui-settings.js +67 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +690 -61
- package/dist/mappings.js +48 -9
- package/dist/pools.js +144 -0
- package/dist/profiles.js +69 -6
- package/dist/protection.js +93 -0
- package/dist/rebind.js +108 -11
- package/dist/service.js +24 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +100 -21
- package/dist/single-load.js +5 -0
- package/package.json +1 -1
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
|
|
71
|
+
function readManifestRaw() {
|
|
72
72
|
try {
|
|
73
73
|
const d = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
+
log(`(note: \`systemctl --user enable --now\` returned nonzero — unit written to ${unit}; enable manually if needed)`);
|
|
107
122
|
writeManifest([unit]);
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
37
|
-
*
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
*
|
|
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
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
$
|
|
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
|
-
|
|
169
|
+
__agent_switch list
|
|
121
170
|
else if contains -- $argv[1] claude codex antigravity
|
|
122
|
-
|
|
171
|
+
__agent_switch use $argv[2] --provider $argv[1]
|
|
123
172
|
else
|
|
124
|
-
|
|
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) {
|
|
134
|
-
if (@('claude','codex','antigravity') -contains $args[0]) {
|
|
135
|
-
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 }
|
|
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
|
|
192
|
+
if [ $# -eq 0 ]; then __agent_switch list; return; fi
|
|
144
193
|
case "$1" in
|
|
145
|
-
claude|codex|antigravity)
|
|
146
|
-
*)
|
|
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
|
}
|
package/dist/single-load.js
CHANGED
|
@@ -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