xclaude-cli 0.1.2
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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/xclaude.js +4293 -0
- package/package.json +54 -0
package/dist/xclaude.js
ADDED
|
@@ -0,0 +1,4293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/runtime-check.ts
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
// src/runtime.ts
|
|
7
|
+
var MIN_NODE = [22, 15];
|
|
8
|
+
function runtimeProblem(platform, nodeVersion) {
|
|
9
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
10
|
+
return `${platform} isn't supported; xclaude runs on macOS and Linux (including WSL)`;
|
|
11
|
+
}
|
|
12
|
+
const [major = 0, minor = 0] = nodeVersion.split(".").map(Number);
|
|
13
|
+
if (major < MIN_NODE[0] || major === MIN_NODE[0] && minor < MIN_NODE[1]) {
|
|
14
|
+
return `Node ${MIN_NODE.join(".")} or later is required (this is ${nodeVersion})`;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/runtime-check.ts
|
|
20
|
+
var problem = runtimeProblem(process.platform, process.versions.node);
|
|
21
|
+
if (problem) {
|
|
22
|
+
fs.writeSync(2, `xclaude: ${problem}
|
|
23
|
+
`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/core/errors.ts
|
|
28
|
+
var EXIT_OK = 0;
|
|
29
|
+
var EXIT_ERROR = 1;
|
|
30
|
+
var EXIT_USAGE = 2;
|
|
31
|
+
var EXIT_CANCELLED = 130;
|
|
32
|
+
var XError = class extends Error {
|
|
33
|
+
exitCode;
|
|
34
|
+
constructor(message, exitCode = EXIT_ERROR) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.exitCode = exitCode;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var UsageError = class extends XError {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message, EXIT_USAGE);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var Cancelled = class extends XError {
|
|
45
|
+
constructor() {
|
|
46
|
+
super("", EXIT_CANCELLED);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/core/io.ts
|
|
51
|
+
import fs2 from "node:fs";
|
|
52
|
+
|
|
53
|
+
// src/core/sleep.ts
|
|
54
|
+
var cell = new Int32Array(new SharedArrayBuffer(4));
|
|
55
|
+
function sleepSync(ms) {
|
|
56
|
+
Atomics.wait(cell, 0, 0, ms);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/core/io.ts
|
|
60
|
+
function writeAll(fd, text) {
|
|
61
|
+
const buf = Buffer.from(text);
|
|
62
|
+
let offset = 0;
|
|
63
|
+
while (offset < buf.length) {
|
|
64
|
+
try {
|
|
65
|
+
offset += fs2.writeSync(fd, buf, offset);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
const code = e.code;
|
|
68
|
+
if (code === "EAGAIN") {
|
|
69
|
+
sleepSync(2);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (code === "EPIPE") return;
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
var stdio = {
|
|
78
|
+
out: (text) => writeAll(1, text),
|
|
79
|
+
err: (text) => writeAll(2, text)
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// src/ctx.ts
|
|
83
|
+
import tty from "node:tty";
|
|
84
|
+
|
|
85
|
+
// src/core/paths.ts
|
|
86
|
+
import fs3 from "node:fs";
|
|
87
|
+
import os from "node:os";
|
|
88
|
+
import path from "node:path";
|
|
89
|
+
function resolvePaths(env) {
|
|
90
|
+
const home = path.resolve(env.HOME || os.homedir());
|
|
91
|
+
const xhome = path.resolve(env.XCLAUDE_HOME || path.join(home, ".xclaude"));
|
|
92
|
+
return {
|
|
93
|
+
home,
|
|
94
|
+
store: path.join(home, ".claude"),
|
|
95
|
+
xhome,
|
|
96
|
+
config: path.join(xhome, "config.json"),
|
|
97
|
+
state: path.join(xhome, "state.json"),
|
|
98
|
+
cache: path.join(xhome, "cache"),
|
|
99
|
+
shell: path.join(xhome, "shell"),
|
|
100
|
+
locks: path.join(xhome, "locks"),
|
|
101
|
+
accounts: path.join(xhome, "accounts")
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function accountDir(paths, name) {
|
|
105
|
+
return path.join(paths.xhome, "accounts", name);
|
|
106
|
+
}
|
|
107
|
+
function ensureXHome(paths) {
|
|
108
|
+
fs3.mkdirSync(paths.xhome, { recursive: true, mode: 448 });
|
|
109
|
+
}
|
|
110
|
+
function mkdirPrivate(dir) {
|
|
111
|
+
fs3.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
112
|
+
}
|
|
113
|
+
function realpathOrNull(p) {
|
|
114
|
+
try {
|
|
115
|
+
return fs3.realpathSync.native(p);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function tildify(p, home) {
|
|
121
|
+
if (p === home) return "~";
|
|
122
|
+
return p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/launch/exec.ts
|
|
126
|
+
import { spawn } from "node:child_process";
|
|
127
|
+
import fs6 from "node:fs";
|
|
128
|
+
import os2 from "node:os";
|
|
129
|
+
|
|
130
|
+
// src/claude/runnable.ts
|
|
131
|
+
import fs5 from "node:fs";
|
|
132
|
+
import path3 from "node:path";
|
|
133
|
+
|
|
134
|
+
// src/claude/resolve.ts
|
|
135
|
+
import fs4 from "node:fs";
|
|
136
|
+
import path2 from "node:path";
|
|
137
|
+
var INSTALL_HINT = "install it with: curl -fsSL https://claude.ai/install.sh | bash (or npm i -g @anthropic-ai/claude-code)";
|
|
138
|
+
function isExecutableFile(p) {
|
|
139
|
+
try {
|
|
140
|
+
fs4.accessSync(p, fs4.constants.X_OK);
|
|
141
|
+
return fs4.statSync(p).isFile();
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function fallbackLocations(home) {
|
|
147
|
+
return [path2.join(home, ".local", "bin", "claude"), "/opt/homebrew/bin/claude", "/usr/local/bin/claude", "/usr/bin/claude"];
|
|
148
|
+
}
|
|
149
|
+
function findClaude(env, config, home, selfPath) {
|
|
150
|
+
const explicit = [
|
|
151
|
+
[env.XCLAUDE_CLAUDE_PATH, "XCLAUDE_CLAUDE_PATH"],
|
|
152
|
+
[config.claudePath, "claudePath in ~/.xclaude/config.json"]
|
|
153
|
+
];
|
|
154
|
+
for (const [value, where] of explicit) {
|
|
155
|
+
if (!value) continue;
|
|
156
|
+
if (isExecutableFile(value)) return value;
|
|
157
|
+
throw new XError(`${where} is set to ${value}, which isn't an executable file`);
|
|
158
|
+
}
|
|
159
|
+
const self = selfPath ? realpathOrNull(selfPath) : null;
|
|
160
|
+
for (const dir of (env.PATH ?? "").split(path2.delimiter)) {
|
|
161
|
+
if (!dir || !path2.isAbsolute(dir)) continue;
|
|
162
|
+
const candidate = path2.join(dir, "claude");
|
|
163
|
+
if (!isExecutableFile(candidate)) continue;
|
|
164
|
+
if (self && realpathOrNull(candidate) === self) continue;
|
|
165
|
+
return candidate;
|
|
166
|
+
}
|
|
167
|
+
for (const candidate of fallbackLocations(home)) {
|
|
168
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
169
|
+
}
|
|
170
|
+
throw new XError(`Claude Code isn't installed (no \`claude\` found); ${INSTALL_HINT}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/claude/runnable.ts
|
|
174
|
+
function readHead(file) {
|
|
175
|
+
try {
|
|
176
|
+
const fd = fs5.openSync(file, "r");
|
|
177
|
+
try {
|
|
178
|
+
const buf = Buffer.alloc(256);
|
|
179
|
+
return buf.subarray(0, fs5.readSync(fd, buf, 0, buf.length, 0));
|
|
180
|
+
} finally {
|
|
181
|
+
fs5.closeSync(fd);
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
var isBinary = (head) => head.length >= 4 && (head[0] === 127 && head.subarray(1, 4).toString("latin1") === "ELF" || [4277009102, 4277009103, 3472551422, 3489328638, 3405691582, 3199925962].includes(head.readUInt32BE(0)));
|
|
188
|
+
var hasShebang = (head) => head[0] === 35 && head[1] === 33;
|
|
189
|
+
function interpreterOf(head) {
|
|
190
|
+
const line = head.subarray(2).toString("utf8").split("\n")[0];
|
|
191
|
+
return /^[ \t]*([^ \t]*)/.exec(line)[1];
|
|
192
|
+
}
|
|
193
|
+
function directlyExecutable(file) {
|
|
194
|
+
const head = readHead(file);
|
|
195
|
+
if (!head) return false;
|
|
196
|
+
if (isBinary(head)) return true;
|
|
197
|
+
if (!hasShebang(head)) return false;
|
|
198
|
+
const interpreter = interpreterOf(head);
|
|
199
|
+
const interpreterHead = path3.isAbsolute(interpreter) && isExecutableFile(interpreter) ? readHead(interpreter) : null;
|
|
200
|
+
return interpreterHead !== null && isBinary(interpreterHead);
|
|
201
|
+
}
|
|
202
|
+
function isShellScript(file) {
|
|
203
|
+
const head = readHead(file);
|
|
204
|
+
return head !== null && head.length > 0 && !isBinary(head) && !hasShebang(head) && !head.includes(0);
|
|
205
|
+
}
|
|
206
|
+
function shellWay(file, args) {
|
|
207
|
+
return isShellScript(file) ? ["/bin/sh", [file, ...args]] : [file, args];
|
|
208
|
+
}
|
|
209
|
+
function isEmptyFile(file) {
|
|
210
|
+
try {
|
|
211
|
+
return fs5.statSync(file).size === 0;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/launch/exec.ts
|
|
218
|
+
function restoreDefaultSignals() {
|
|
219
|
+
const noop = () => {
|
|
220
|
+
};
|
|
221
|
+
for (const sig of ["SIGPIPE", "SIGXFSZ"]) {
|
|
222
|
+
process.on(sig, noop);
|
|
223
|
+
process.off(sig, noop);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function makeExec(opts) {
|
|
227
|
+
return async (file, argv, env) => {
|
|
228
|
+
try {
|
|
229
|
+
fs6.accessSync(file, fs6.constants.X_OK);
|
|
230
|
+
} catch {
|
|
231
|
+
throw new XError(`${file} isn't executable`);
|
|
232
|
+
}
|
|
233
|
+
if (isEmptyFile(file)) throw new XError(`${file} is empty, so there's nothing to run`);
|
|
234
|
+
const [runFile, args] = shellWay(file, argv.slice(1));
|
|
235
|
+
const runArgv = runFile === file ? argv : ["sh", ...args];
|
|
236
|
+
if (!opts.spawnFallback && typeof process.execve === "function" && directlyExecutable(runFile)) {
|
|
237
|
+
restoreDefaultSignals();
|
|
238
|
+
try {
|
|
239
|
+
process.execve(runFile, runArgv, env);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
throw new XError(`couldn't start ${file}: ${e.message}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return spawnAndMirror(runFile, args, env);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function spawnAndMirror(file, args, env) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const child = spawn(file, args, { stdio: "inherit", env });
|
|
250
|
+
const ignore = () => {
|
|
251
|
+
};
|
|
252
|
+
const forwardTerm = () => child.kill("SIGTERM");
|
|
253
|
+
const forwardHup = () => child.kill("SIGHUP");
|
|
254
|
+
process.on("SIGINT", ignore);
|
|
255
|
+
process.on("SIGQUIT", ignore);
|
|
256
|
+
process.on("SIGTERM", forwardTerm);
|
|
257
|
+
process.on("SIGHUP", forwardHup);
|
|
258
|
+
const cleanup = () => {
|
|
259
|
+
process.off("SIGINT", ignore);
|
|
260
|
+
process.off("SIGQUIT", ignore);
|
|
261
|
+
process.off("SIGTERM", forwardTerm);
|
|
262
|
+
process.off("SIGHUP", forwardHup);
|
|
263
|
+
};
|
|
264
|
+
child.on("error", (e) => {
|
|
265
|
+
cleanup();
|
|
266
|
+
reject(new XError(`couldn't start ${file}: ${e.message}`));
|
|
267
|
+
});
|
|
268
|
+
child.on("exit", (code, signal) => {
|
|
269
|
+
cleanup();
|
|
270
|
+
if (signal) {
|
|
271
|
+
process.kill(process.pid, signal);
|
|
272
|
+
resolve(128 + (os2.constants.signals[signal] ?? 0));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve(code ?? 1);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function runChild(file, args, env) {
|
|
280
|
+
if (isEmptyFile(file)) return Promise.reject(new XError(`${file} is empty, so there's nothing to run`));
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const child = spawn(...shellWay(file, args), { stdio: "inherit", env });
|
|
283
|
+
const ignore = () => {
|
|
284
|
+
};
|
|
285
|
+
process.on("SIGINT", ignore);
|
|
286
|
+
process.on("SIGQUIT", ignore);
|
|
287
|
+
const done = () => {
|
|
288
|
+
process.off("SIGINT", ignore);
|
|
289
|
+
process.off("SIGQUIT", ignore);
|
|
290
|
+
};
|
|
291
|
+
child.on("error", (e) => {
|
|
292
|
+
done();
|
|
293
|
+
reject(new XError(`couldn't start ${file}: ${e.message}`));
|
|
294
|
+
});
|
|
295
|
+
child.on("exit", (code, signal) => {
|
|
296
|
+
done();
|
|
297
|
+
resolve(signal ? 128 + (os2.constants.signals[signal] ?? 0) : code ?? 1);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/switches.ts
|
|
303
|
+
var SWITCHES = {
|
|
304
|
+
shareSessions: true,
|
|
305
|
+
agentsPerAccount: false,
|
|
306
|
+
shareHistory: true,
|
|
307
|
+
shareSkills: true,
|
|
308
|
+
shareChrome: true,
|
|
309
|
+
spawnFallback: false,
|
|
310
|
+
linkIde: false,
|
|
311
|
+
normalizePaths: false,
|
|
312
|
+
keepSameEmailLogin: false
|
|
313
|
+
};
|
|
314
|
+
function loadSwitches(env) {
|
|
315
|
+
const switches = { ...SWITCHES };
|
|
316
|
+
for (const item of (env.XCLAUDE_SWITCHES ?? "").split(",")) {
|
|
317
|
+
const [name = "", value = "1"] = item.trim().split("=");
|
|
318
|
+
if (Object.hasOwn(switches, name)) switches[name] = value === "1" || value === "true";
|
|
319
|
+
}
|
|
320
|
+
return switches;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/ctx.ts
|
|
324
|
+
function processCtx(io) {
|
|
325
|
+
const switches = loadSwitches(process.env);
|
|
326
|
+
return {
|
|
327
|
+
env: process.env,
|
|
328
|
+
cwd: process.cwd(),
|
|
329
|
+
paths: resolvePaths(process.env),
|
|
330
|
+
io,
|
|
331
|
+
// tty.isatty, never process.stdin.isTTY: touching Node's stdio streams makes
|
|
332
|
+
// a terminal fd non-blocking, and claude would inherit that through exec.
|
|
333
|
+
tty: { stdin: tty.isatty(0), stdout: tty.isatty(1), stderr: tty.isatty(2) },
|
|
334
|
+
switches,
|
|
335
|
+
exec: makeExec({ spawnFallback: switches.spawnFallback }),
|
|
336
|
+
selfPath: process.argv[1] ?? null
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/commands/add.ts
|
|
341
|
+
import fs14 from "node:fs";
|
|
342
|
+
|
|
343
|
+
// src/claude/auth.ts
|
|
344
|
+
import { execFile } from "node:child_process";
|
|
345
|
+
function runClaude(claude, args, env, timeoutMs = 3e4) {
|
|
346
|
+
return new Promise((resolve) => {
|
|
347
|
+
const [file, fileArgs] = shellWay(claude, args);
|
|
348
|
+
execFile(file, fileArgs, { env, timeout: timeoutMs, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
349
|
+
const code = error ? typeof error.code === "number" ? error.code : null : 0;
|
|
350
|
+
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? (error ? error.message : "") });
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
async function authStatus(claude, env) {
|
|
355
|
+
const res = await runClaude(claude, ["auth", "status"], env);
|
|
356
|
+
try {
|
|
357
|
+
const json = JSON.parse(res.stdout);
|
|
358
|
+
const str = (v) => typeof v === "string" && v ? v : null;
|
|
359
|
+
return {
|
|
360
|
+
loggedIn: json.loggedIn === true,
|
|
361
|
+
email: str(json.email),
|
|
362
|
+
orgId: str(json.orgId),
|
|
363
|
+
orgName: str(json.orgName),
|
|
364
|
+
error: null
|
|
365
|
+
};
|
|
366
|
+
} catch {
|
|
367
|
+
const why = (res.stderr || res.stdout).trim().split("\n")[0] || `exit code ${res.code}`;
|
|
368
|
+
return { loggedIn: false, email: null, orgId: null, orgName: null, error: why };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function describeLogin(s) {
|
|
372
|
+
if (s.error) return `unknown (${s.error})`;
|
|
373
|
+
if (!s.loggedIn) return "not logged in";
|
|
374
|
+
const who = s.email ?? "logged in";
|
|
375
|
+
return s.orgName ? `${who} (${s.orgName})` : who;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/core/config.ts
|
|
379
|
+
import fs8 from "node:fs";
|
|
380
|
+
|
|
381
|
+
// src/core/fsutil.ts
|
|
382
|
+
import fs7 from "node:fs";
|
|
383
|
+
import path4 from "node:path";
|
|
384
|
+
var fsops = {
|
|
385
|
+
link: (src, dst) => fs7.linkSync(src, dst)
|
|
386
|
+
};
|
|
387
|
+
function errCode(e) {
|
|
388
|
+
return e?.code;
|
|
389
|
+
}
|
|
390
|
+
function lstatOrNull(p) {
|
|
391
|
+
try {
|
|
392
|
+
return fs7.lstatSync(p);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
if (errCode(e) === "ENOENT" || errCode(e) === "ENOTDIR") return null;
|
|
395
|
+
throw e;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function statOrNull(p) {
|
|
399
|
+
try {
|
|
400
|
+
return fs7.statSync(p);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
if (errCode(e) === "ENOENT" || errCode(e) === "ENOTDIR" || errCode(e) === "ELOOP") return null;
|
|
403
|
+
throw e;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function readlinkOrNull(p) {
|
|
407
|
+
try {
|
|
408
|
+
return fs7.readlinkSync(p);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
if (errCode(e) === "ENOENT" || errCode(e) === "EINVAL" || errCode(e) === "ENOTDIR") return null;
|
|
411
|
+
throw e;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function readFileOrNull(p) {
|
|
415
|
+
try {
|
|
416
|
+
return fs7.readFileSync(p, "utf8");
|
|
417
|
+
} catch (e) {
|
|
418
|
+
if (errCode(e) === "ENOENT" || errCode(e) === "ENOTDIR") return null;
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
var counter = 0;
|
|
423
|
+
function siblingName(target, tag) {
|
|
424
|
+
return path4.join(path4.dirname(target), `.xclaude-${tag}-${path4.basename(target)}-${process.pid}-${Date.now()}-${counter++}`);
|
|
425
|
+
}
|
|
426
|
+
function writeFileAtomic(path_, data, opts = {}) {
|
|
427
|
+
let file = path_;
|
|
428
|
+
try {
|
|
429
|
+
file = fs7.realpathSync(path_);
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
const existing = statOrNull(file);
|
|
433
|
+
const mode = existing ? existing.mode & 4095 : opts.mode ?? 384;
|
|
434
|
+
const tmp = siblingName(file, "tmp");
|
|
435
|
+
const fd = fs7.openSync(tmp, "wx", mode);
|
|
436
|
+
try {
|
|
437
|
+
fs7.writeFileSync(fd, data);
|
|
438
|
+
if (opts.fsync) fs7.fsyncSync(fd);
|
|
439
|
+
} finally {
|
|
440
|
+
fs7.closeSync(fd);
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
fs7.chmodSync(tmp, mode);
|
|
444
|
+
fs7.renameSync(tmp, file);
|
|
445
|
+
} catch (e) {
|
|
446
|
+
fs7.rmSync(tmp, { force: true });
|
|
447
|
+
throw e;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function removeTree(p) {
|
|
451
|
+
const st = lstatOrNull(p);
|
|
452
|
+
if (!st) return;
|
|
453
|
+
if (!st.isDirectory()) {
|
|
454
|
+
fs7.unlinkSync(p);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const retry = (op) => {
|
|
458
|
+
try {
|
|
459
|
+
return op();
|
|
460
|
+
} catch (e) {
|
|
461
|
+
if (errCode(e) !== "EACCES" && errCode(e) !== "EPERM") throw e;
|
|
462
|
+
fs7.chmodSync(p, 448);
|
|
463
|
+
return op();
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
for (const name of retry(() => fs7.readdirSync(p))) {
|
|
467
|
+
retry(() => removeTree(path4.join(p, name)));
|
|
468
|
+
}
|
|
469
|
+
fs7.rmdirSync(p);
|
|
470
|
+
}
|
|
471
|
+
function moveNoClobber(src, dst, srcStat) {
|
|
472
|
+
try {
|
|
473
|
+
if (srcStat.isSymbolicLink()) fs7.symlinkSync(fs7.readlinkSync(src), dst);
|
|
474
|
+
else fsops.link(src, dst);
|
|
475
|
+
} catch (e) {
|
|
476
|
+
if (errCode(e) === "EEXIST") return "exists";
|
|
477
|
+
throw e;
|
|
478
|
+
}
|
|
479
|
+
fs7.unlinkSync(src);
|
|
480
|
+
return "moved";
|
|
481
|
+
}
|
|
482
|
+
function sameContent(a, b) {
|
|
483
|
+
const sa = fs7.statSync(a);
|
|
484
|
+
const sb = fs7.statSync(b);
|
|
485
|
+
if (sa.size !== sb.size) return false;
|
|
486
|
+
if (sa.ino === sb.ino && sa.dev === sb.dev) return true;
|
|
487
|
+
const fa = fs7.openSync(a, "r");
|
|
488
|
+
const fb = fs7.openSync(b, "r");
|
|
489
|
+
try {
|
|
490
|
+
const ba = Buffer.alloc(65536);
|
|
491
|
+
const bb = Buffer.alloc(65536);
|
|
492
|
+
for (; ; ) {
|
|
493
|
+
const na = fs7.readSync(fa, ba, 0, ba.length, null);
|
|
494
|
+
const nb = fs7.readSync(fb, bb, 0, bb.length, null);
|
|
495
|
+
if (na !== nb) return false;
|
|
496
|
+
if (na === 0) return true;
|
|
497
|
+
if (!ba.subarray(0, na).equals(bb.subarray(0, nb))) return false;
|
|
498
|
+
}
|
|
499
|
+
} finally {
|
|
500
|
+
fs7.closeSync(fa);
|
|
501
|
+
fs7.closeSync(fb);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function stamp(now = /* @__PURE__ */ new Date()) {
|
|
505
|
+
return now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/core/config.ts
|
|
509
|
+
var EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultracode"];
|
|
510
|
+
var COMMANDS = ["add", "rm", "ls", "set", "tmux", "doctor", "shell", "guard", "help"];
|
|
511
|
+
var MAIN = "main";
|
|
512
|
+
var ACCOUNT_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
513
|
+
function defaultConfig() {
|
|
514
|
+
return {
|
|
515
|
+
version: 1,
|
|
516
|
+
main: { enabled: false, model: null, effort: null, args: [] },
|
|
517
|
+
accounts: {},
|
|
518
|
+
share: { add: [], remove: [] },
|
|
519
|
+
guard: false,
|
|
520
|
+
tmux: { statusRight: true },
|
|
521
|
+
claudePath: null
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function isEffort(value) {
|
|
525
|
+
return EFFORTS.includes(value);
|
|
526
|
+
}
|
|
527
|
+
function accountNameProblem(name) {
|
|
528
|
+
if (name === MAIN) return `"${MAIN}" is reserved for the main identity (xclaude set main --enable)`;
|
|
529
|
+
if (COMMANDS.includes(name)) return `"${name}" is reserved: it's an xclaude command`;
|
|
530
|
+
if (name.startsWith("-") || name.startsWith("_")) return `"${name}" is reserved: names can't start with - or _`;
|
|
531
|
+
if (!ACCOUNT_NAME.test(name)) {
|
|
532
|
+
return `"${name}" isn't a valid account name: use lowercase letters, digits and dashes, start with a letter, at most 32 characters`;
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
function isEntryName(name) {
|
|
537
|
+
return name.length > 0 && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\0");
|
|
538
|
+
}
|
|
539
|
+
var ConfigProblem = class extends Error {
|
|
540
|
+
};
|
|
541
|
+
function isObject(v) {
|
|
542
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
543
|
+
}
|
|
544
|
+
function field(obj, key, where, fallback, check, expected) {
|
|
545
|
+
if (!(key in obj) || obj[key] === void 0) return fallback;
|
|
546
|
+
const v = obj[key];
|
|
547
|
+
if (!check(v)) throw new ConfigProblem(`${where}${key} must be ${expected}`);
|
|
548
|
+
return v;
|
|
549
|
+
}
|
|
550
|
+
var isBool = (v) => typeof v === "boolean";
|
|
551
|
+
var isStringOrNull = (v) => v === null || typeof v === "string" && v.length > 0;
|
|
552
|
+
var isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
553
|
+
var isEffortOrNull = (v) => v === null || typeof v === "string" && isEffort(v);
|
|
554
|
+
function parseDefaults(raw, where) {
|
|
555
|
+
if (!isObject(raw)) throw new ConfigProblem(`${where.replace(/\.$/, "")} must be an object`);
|
|
556
|
+
return {
|
|
557
|
+
...raw,
|
|
558
|
+
model: field(raw, "model", where, null, isStringOrNull, "a model name or null"),
|
|
559
|
+
effort: field(raw, "effort", where, null, isEffortOrNull, `one of ${EFFORTS.join(", ")}, or null`),
|
|
560
|
+
args: field(raw, "args", where, [], isStringArray, "a list of strings")
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function validateConfig(raw) {
|
|
564
|
+
if (!isObject(raw)) throw new ConfigProblem("the top level must be an object");
|
|
565
|
+
const version = raw.version ?? 1;
|
|
566
|
+
if (version !== 1) throw new ConfigProblem(`version ${JSON.stringify(version)} isn't supported (expected 1)`);
|
|
567
|
+
const mainRaw = raw.main ?? {};
|
|
568
|
+
const main2 = parseDefaults(mainRaw, "main.");
|
|
569
|
+
const enabled = field(mainRaw, "enabled", "main.", false, isBool, "true or false");
|
|
570
|
+
const accountsRaw = raw.accounts ?? {};
|
|
571
|
+
if (!isObject(accountsRaw)) throw new ConfigProblem("accounts must be an object");
|
|
572
|
+
const accounts = {};
|
|
573
|
+
for (const [name, value] of Object.entries(accountsRaw)) {
|
|
574
|
+
const problem2 = accountNameProblem(name);
|
|
575
|
+
if (problem2) throw new ConfigProblem(`accounts: ${problem2}`);
|
|
576
|
+
accounts[name] = parseDefaults(value, `accounts.${name}.`);
|
|
577
|
+
}
|
|
578
|
+
const shareRaw = raw.share ?? {};
|
|
579
|
+
if (!isObject(shareRaw)) throw new ConfigProblem("share must be an object");
|
|
580
|
+
const share = {
|
|
581
|
+
...shareRaw,
|
|
582
|
+
add: field(shareRaw, "add", "share.", [], isStringArray, "a list of entry names"),
|
|
583
|
+
remove: field(shareRaw, "remove", "share.", [], isStringArray, "a list of entry names")
|
|
584
|
+
};
|
|
585
|
+
for (const name of [...share.add, ...share.remove]) {
|
|
586
|
+
if (!isEntryName(name)) throw new ConfigProblem(`share: "${name}" isn't an entry name (one path component)`);
|
|
587
|
+
}
|
|
588
|
+
const tmuxRaw = raw.tmux ?? {};
|
|
589
|
+
if (!isObject(tmuxRaw)) throw new ConfigProblem("tmux must be an object");
|
|
590
|
+
return {
|
|
591
|
+
...raw,
|
|
592
|
+
version: 1,
|
|
593
|
+
main: { ...main2, enabled },
|
|
594
|
+
accounts,
|
|
595
|
+
share,
|
|
596
|
+
guard: field(raw, "guard", "", false, isBool, "true or false"),
|
|
597
|
+
tmux: { ...tmuxRaw, statusRight: field(tmuxRaw, "statusRight", "tmux.", true, isBool, "true or false") },
|
|
598
|
+
claudePath: field(raw, "claudePath", "", null, isStringOrNull, "a path or null")
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function parseConfig(text, file) {
|
|
602
|
+
let raw;
|
|
603
|
+
try {
|
|
604
|
+
raw = JSON.parse(text);
|
|
605
|
+
} catch (e) {
|
|
606
|
+
throw new XError(`${file} isn't valid JSON: ${e.message}`);
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
return validateConfig(raw);
|
|
610
|
+
} catch (e) {
|
|
611
|
+
if (e instanceof ConfigProblem) throw new XError(`${file}: ${e.message}`);
|
|
612
|
+
throw e;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function loadConfig(paths, opts) {
|
|
616
|
+
let text;
|
|
617
|
+
try {
|
|
618
|
+
text = fs8.readFileSync(paths.config, "utf8");
|
|
619
|
+
} catch (e) {
|
|
620
|
+
if (errCode(e) !== "ENOENT") throw e;
|
|
621
|
+
const config = defaultConfig();
|
|
622
|
+
if (!opts.create) return { config, created: false };
|
|
623
|
+
ensureXHome(paths);
|
|
624
|
+
saveConfig(paths, config);
|
|
625
|
+
if (!fs8.existsSync(paths.state)) writeFileAtomic(paths.state, "{}\n", { mode: 384 });
|
|
626
|
+
return { config, created: true };
|
|
627
|
+
}
|
|
628
|
+
return { config: parseConfig(text, tildify(paths.config, paths.home)), created: false };
|
|
629
|
+
}
|
|
630
|
+
function saveConfig(paths, config) {
|
|
631
|
+
ensureXHome(paths);
|
|
632
|
+
writeFileAtomic(paths.config, `${JSON.stringify(config, null, 2)}
|
|
633
|
+
`, { mode: 384, fsync: true });
|
|
634
|
+
}
|
|
635
|
+
function identities(config) {
|
|
636
|
+
const names = Object.keys(config.accounts);
|
|
637
|
+
if (config.main.enabled) names.push(MAIN);
|
|
638
|
+
return names;
|
|
639
|
+
}
|
|
640
|
+
function defaultsOf(config, name) {
|
|
641
|
+
if (name === MAIN) return config.main.enabled ? config.main : null;
|
|
642
|
+
return Object.hasOwn(config.accounts, name) ? config.accounts[name] : null;
|
|
643
|
+
}
|
|
644
|
+
function describeDefaults(d) {
|
|
645
|
+
const parts = [d.model, d.effort, d.args.length ? d.args.join(" ") : null].filter((x) => Boolean(x));
|
|
646
|
+
return parts.join(" \xB7 ");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/core/options.ts
|
|
650
|
+
function parseOptions(args, defs, command) {
|
|
651
|
+
const values = {};
|
|
652
|
+
const positionals = [];
|
|
653
|
+
const byShort = new Map(Object.entries(defs).filter(([, d]) => d.short).map(([n, d]) => [`-${d.short}`, n]));
|
|
654
|
+
for (let i = 0; i < args.length; i++) {
|
|
655
|
+
const a = args[i];
|
|
656
|
+
if (a === "--") {
|
|
657
|
+
positionals.push(...args.slice(i + 1));
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
if (!a.startsWith("-") || a === "-") {
|
|
661
|
+
positionals.push(a);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const eq = a.indexOf("=");
|
|
665
|
+
const flag = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
|
|
666
|
+
const name = flag.startsWith("--") ? flag.slice(2) : byShort.get(flag);
|
|
667
|
+
const def = name !== void 0 ? defs[name] : void 0;
|
|
668
|
+
if (name === void 0 || !def) throw new UsageError(`${command}: unknown option ${flag} (see xclaude help ${command})`);
|
|
669
|
+
let value = true;
|
|
670
|
+
if (def.value) {
|
|
671
|
+
if (eq > 0 && flag !== a) value = a.slice(eq + 1);
|
|
672
|
+
else if (i + 1 < args.length) value = args[++i];
|
|
673
|
+
else throw new UsageError(`${command}: ${flag} needs a value`);
|
|
674
|
+
} else if (flag !== a) {
|
|
675
|
+
throw new UsageError(`${command}: ${flag} doesn't take a value`);
|
|
676
|
+
}
|
|
677
|
+
if (def.repeat) values[name] = [...values[name] ?? [], value];
|
|
678
|
+
else values[name] = value;
|
|
679
|
+
}
|
|
680
|
+
return { values, positionals };
|
|
681
|
+
}
|
|
682
|
+
function stringOption(p, name) {
|
|
683
|
+
const v = p.values[name];
|
|
684
|
+
return typeof v === "string" ? v : void 0;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/launch/env.ts
|
|
688
|
+
function buildEnv(base, identity) {
|
|
689
|
+
const env = {};
|
|
690
|
+
for (const [k, v] of Object.entries(base)) if (v !== void 0) env[k] = v;
|
|
691
|
+
if (identity.configDir) env.CLAUDE_CONFIG_DIR = identity.configDir;
|
|
692
|
+
else delete env.CLAUDE_CONFIG_DIR;
|
|
693
|
+
delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
694
|
+
env.XCLAUDE_ACCOUNT = identity.name;
|
|
695
|
+
return env;
|
|
696
|
+
}
|
|
697
|
+
function identityEnv(ctx, name) {
|
|
698
|
+
return buildEnv(ctx.env, { name, configDir: name === MAIN ? null : accountDir(ctx.paths, name) });
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/link/repair.ts
|
|
702
|
+
import fs13 from "node:fs";
|
|
703
|
+
import path9 from "node:path";
|
|
704
|
+
|
|
705
|
+
// src/core/lock.ts
|
|
706
|
+
import fs9 from "node:fs";
|
|
707
|
+
import path5 from "node:path";
|
|
708
|
+
var STORE_LOCK = { staleMs: 3e4, refreshMs: 5e3 };
|
|
709
|
+
var HISTORY_LOCK = { staleMs: 1e4, refreshMs: 2500 };
|
|
710
|
+
var DirLock = class _DirLock {
|
|
711
|
+
path;
|
|
712
|
+
timing;
|
|
713
|
+
mtimeMs = 0;
|
|
714
|
+
lastRefresh = 0;
|
|
715
|
+
released = false;
|
|
716
|
+
constructor(lockPath, timing) {
|
|
717
|
+
this.path = lockPath;
|
|
718
|
+
this.timing = timing;
|
|
719
|
+
this.stampNow();
|
|
720
|
+
}
|
|
721
|
+
/** Takes the lock, or returns null if it stayed busy for waitMs. */
|
|
722
|
+
static acquire(lockPath, timing, opts) {
|
|
723
|
+
const deadline = Date.now() + opts.waitMs;
|
|
724
|
+
for (; ; ) {
|
|
725
|
+
try {
|
|
726
|
+
fs9.mkdirSync(lockPath, { mode: 448 });
|
|
727
|
+
return new _DirLock(lockPath, timing);
|
|
728
|
+
} catch (e) {
|
|
729
|
+
if (errCode(e) === "ENOENT") {
|
|
730
|
+
fs9.mkdirSync(path5.dirname(lockPath), { recursive: true, mode: 448 });
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
734
|
+
}
|
|
735
|
+
const st = statOrNull(lockPath);
|
|
736
|
+
if (st && Date.now() - st.mtimeMs > timing.staleMs) {
|
|
737
|
+
takeOverStale(lockPath);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (Date.now() >= deadline) return null;
|
|
741
|
+
opts.onWait?.();
|
|
742
|
+
sleepSync(opts.pollMs ?? 50);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/** True while the lock dir exists with the mtime this holder last set. */
|
|
746
|
+
held() {
|
|
747
|
+
if (this.released) return false;
|
|
748
|
+
const st = statOrNull(this.path);
|
|
749
|
+
return st !== null && st.mtimeMs === this.mtimeMs;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Refreshes the mtime if refreshMs has passed (or always, with force).
|
|
753
|
+
* Returns false, without touching anything, when the lock is no longer ours.
|
|
754
|
+
*/
|
|
755
|
+
refresh(force = false) {
|
|
756
|
+
if (!this.held()) return false;
|
|
757
|
+
if (force || Date.now() - this.lastRefresh >= this.timing.refreshMs) this.stampNow();
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
/** Removes the lock dir, but only while it's still ours. */
|
|
761
|
+
release() {
|
|
762
|
+
if (this.held()) {
|
|
763
|
+
try {
|
|
764
|
+
fs9.rmdirSync(this.path);
|
|
765
|
+
} catch (e) {
|
|
766
|
+
if (errCode(e) !== "ENOENT") throw e;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
this.released = true;
|
|
770
|
+
}
|
|
771
|
+
stampNow() {
|
|
772
|
+
const now = /* @__PURE__ */ new Date();
|
|
773
|
+
fs9.utimesSync(this.path, now, now);
|
|
774
|
+
this.mtimeMs = fs9.statSync(this.path).mtimeMs;
|
|
775
|
+
this.lastRefresh = Date.now();
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
function takeOverStale(lockPath) {
|
|
779
|
+
try {
|
|
780
|
+
fs9.rmdirSync(lockPath);
|
|
781
|
+
} catch (e) {
|
|
782
|
+
if (errCode(e) === "ENOENT") return;
|
|
783
|
+
if (errCode(e) === "ENOTEMPTY" || errCode(e) === "EEXIST") removeTree(lockPath);
|
|
784
|
+
else throw e;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// src/link/history.ts
|
|
789
|
+
import fs11 from "node:fs";
|
|
790
|
+
import path7 from "node:path";
|
|
791
|
+
|
|
792
|
+
// src/link/merge.ts
|
|
793
|
+
import fs10 from "node:fs";
|
|
794
|
+
import path6 from "node:path";
|
|
795
|
+
var counter2 = 0;
|
|
796
|
+
function asideName(entry) {
|
|
797
|
+
return `.xclaude-merge-${entry}-${stamp()}-${process.pid}-${counter2++}`;
|
|
798
|
+
}
|
|
799
|
+
function parseAside(name) {
|
|
800
|
+
const m = /^\.xclaude-merge-(.+)-\d{8}T\d{6}Z-\d+-\d+$/.exec(name);
|
|
801
|
+
return m ? m[1] : null;
|
|
802
|
+
}
|
|
803
|
+
var LockLost = class extends Error {
|
|
804
|
+
constructor() {
|
|
805
|
+
super("another xclaude took over the repair lock");
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
function emptyStats() {
|
|
809
|
+
return { moved: 0, dropped: 0, conflicts: [] };
|
|
810
|
+
}
|
|
811
|
+
function newRecord() {
|
|
812
|
+
return { placed: /* @__PURE__ */ new Map(), created: [], byPath: /* @__PURE__ */ new Map() };
|
|
813
|
+
}
|
|
814
|
+
function remember(rec, c) {
|
|
815
|
+
rec.created.push(c);
|
|
816
|
+
rec.byPath.set(c.path, c);
|
|
817
|
+
}
|
|
818
|
+
function requireRealDir(dir) {
|
|
819
|
+
const st = fs10.lstatSync(dir);
|
|
820
|
+
if (!st.isDirectory()) throw new Error(`${dir} isn't a real directory; it won't be read through`);
|
|
821
|
+
return st;
|
|
822
|
+
}
|
|
823
|
+
function linkText(src, root) {
|
|
824
|
+
const text = fs10.readlinkSync(src);
|
|
825
|
+
if (path6.isAbsolute(text)) return text;
|
|
826
|
+
const resolved = path6.resolve(path6.dirname(src), text);
|
|
827
|
+
return resolved === root || resolved.startsWith(`${root}${path6.sep}`) ? text : resolved;
|
|
828
|
+
}
|
|
829
|
+
function identicalEntry(src, srcStat, dst, root) {
|
|
830
|
+
const dstStat = lstatOrNull(dst);
|
|
831
|
+
if (!dstStat) return false;
|
|
832
|
+
if (srcStat.isSymbolicLink()) {
|
|
833
|
+
if (!dstStat.isSymbolicLink()) return false;
|
|
834
|
+
const there = fs10.readlinkSync(dst);
|
|
835
|
+
return there === linkText(src, root) || there === fs10.readlinkSync(src);
|
|
836
|
+
}
|
|
837
|
+
return srcStat.isFile() && dstStat.isFile() && sameContent(src, dst);
|
|
838
|
+
}
|
|
839
|
+
function sameInode(a, b) {
|
|
840
|
+
return b !== null && a.ino === b.ino && a.dev === b.dev;
|
|
841
|
+
}
|
|
842
|
+
function stillPlaced(p, src, st, root) {
|
|
843
|
+
if (p.identical || st.isSymbolicLink()) return identicalEntry(src, st, p.target, root);
|
|
844
|
+
return sameInode(st, lstatOrNull(p.target));
|
|
845
|
+
}
|
|
846
|
+
function conflictName(dir, name, ctx) {
|
|
847
|
+
const base = `${name}.xclaude-conflict-${ctx.account}-${ctx.stamp}`;
|
|
848
|
+
for (let n = 1; ; n++) {
|
|
849
|
+
const candidate = n === 1 ? base : `${base}-${n}`;
|
|
850
|
+
if (!lstatOrNull(path6.join(dir, candidate))) return candidate;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
var join = (rel, name) => rel ? `${rel}/${name}` : name;
|
|
854
|
+
function linkTree(src, dst, ctx, stats, rec, root = src, rel = "") {
|
|
855
|
+
let fresh = 0;
|
|
856
|
+
for (const d of fs10.readdirSync(src, { withFileTypes: true })) {
|
|
857
|
+
ctx.refresh();
|
|
858
|
+
const s = path6.join(src, d.name);
|
|
859
|
+
const t = path6.join(dst, d.name);
|
|
860
|
+
const relName = join(rel, d.name);
|
|
861
|
+
const st = fs10.lstatSync(s);
|
|
862
|
+
const prior = rec.placed.get(relName);
|
|
863
|
+
if (st.isDirectory()) {
|
|
864
|
+
let target = t;
|
|
865
|
+
if (prior && lstatOrNull(prior.target)?.isDirectory()) {
|
|
866
|
+
target = prior.target;
|
|
867
|
+
} else {
|
|
868
|
+
try {
|
|
869
|
+
fs10.mkdirSync(t, { mode: st.mode & 4095 | 448 });
|
|
870
|
+
remember(rec, { path: t, kind: "dir" });
|
|
871
|
+
} catch (e) {
|
|
872
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
873
|
+
if (!fs10.lstatSync(t).isDirectory()) {
|
|
874
|
+
const name2 = conflictName(dst, d.name, ctx);
|
|
875
|
+
target = path6.join(dst, name2);
|
|
876
|
+
fs10.mkdirSync(target, { mode: st.mode & 4095 | 448 });
|
|
877
|
+
remember(rec, { path: target, kind: "dir" });
|
|
878
|
+
stats.conflicts.push(join(rel, name2));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
rec.placed.set(relName, { target });
|
|
882
|
+
fresh++;
|
|
883
|
+
}
|
|
884
|
+
fresh += linkTree(s, target, ctx, stats, rec, root, relName);
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (prior && stillPlaced(prior, s, st, root)) continue;
|
|
888
|
+
const place = (to) => {
|
|
889
|
+
if (st.isSymbolicLink()) {
|
|
890
|
+
const link = linkText(s, root);
|
|
891
|
+
fs10.symlinkSync(link, to);
|
|
892
|
+
remember(rec, { path: to, kind: "symlink", link });
|
|
893
|
+
} else {
|
|
894
|
+
fsops.link(s, to);
|
|
895
|
+
remember(rec, { path: to, kind: "file", ino: st.ino, dev: st.dev });
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
fresh++;
|
|
899
|
+
try {
|
|
900
|
+
place(t);
|
|
901
|
+
rec.placed.set(relName, { target: t });
|
|
902
|
+
stats.moved++;
|
|
903
|
+
continue;
|
|
904
|
+
} catch (e) {
|
|
905
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
906
|
+
}
|
|
907
|
+
if (!st.isSymbolicLink() && sameInode(st, lstatOrNull(t))) {
|
|
908
|
+
rec.placed.set(relName, { target: t });
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (identicalEntry(s, st, t, root)) {
|
|
912
|
+
rec.placed.set(relName, { target: t, identical: true });
|
|
913
|
+
stats.dropped++;
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const name = conflictName(dst, d.name, ctx);
|
|
917
|
+
place(path6.join(dst, name));
|
|
918
|
+
rec.placed.set(relName, { target: path6.join(dst, name) });
|
|
919
|
+
stats.moved++;
|
|
920
|
+
stats.conflicts.push(join(rel, name));
|
|
921
|
+
}
|
|
922
|
+
return fresh;
|
|
923
|
+
}
|
|
924
|
+
function undoLinks(rec) {
|
|
925
|
+
for (const c of [...rec.created].reverse()) {
|
|
926
|
+
const st = lstatOrNull(c.path);
|
|
927
|
+
if (!st) continue;
|
|
928
|
+
try {
|
|
929
|
+
if (c.kind === "dir" && st.isDirectory()) fs10.rmdirSync(c.path);
|
|
930
|
+
else if (c.kind === "symlink" && st.isSymbolicLink() && fs10.readlinkSync(c.path) === c.link) fs10.unlinkSync(c.path);
|
|
931
|
+
else if (c.kind === "file" && st.ino === c.ino && st.dev === c.dev) fs10.unlinkSync(c.path);
|
|
932
|
+
} catch {
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function openForEmptying(dir) {
|
|
937
|
+
const st = requireRealDir(dir);
|
|
938
|
+
if ((st.mode & 448) !== 448) fs10.chmodSync(dir, st.mode & 4095 | 448);
|
|
939
|
+
return fs10.readdirSync(dir, { withFileTypes: true });
|
|
940
|
+
}
|
|
941
|
+
function takeOver(src, placed, rec, dst, ctx, stats, rel) {
|
|
942
|
+
const ours = rec.byPath.get(placed.target);
|
|
943
|
+
const there = lstatOrNull(placed.target);
|
|
944
|
+
if (!ours || ours.kind !== "file" || !there || there.ino !== ours.ino || there.dev !== ours.dev) return false;
|
|
945
|
+
const base = path6.basename(placed.target);
|
|
946
|
+
const old = conflictName(dst, base, ctx);
|
|
947
|
+
fsops.link(placed.target, path6.join(dst, old));
|
|
948
|
+
const tmp = path6.join(dst, `.xclaude-tmp-${base}-${process.pid}-${counter2++}`);
|
|
949
|
+
fsops.link(src, tmp);
|
|
950
|
+
fs10.renameSync(tmp, placed.target);
|
|
951
|
+
fs10.unlinkSync(src);
|
|
952
|
+
stats.conflicts.push(join(rel, old));
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
function moveTree(src, dst, ctx, stats, rec = null, root = src, rel = "") {
|
|
956
|
+
const entries = openForEmptying(src);
|
|
957
|
+
const isPlaced = (name) => Boolean(rec?.placed.has(join(rel, name)));
|
|
958
|
+
entries.sort((a, b) => Number(isPlaced(a.name)) - Number(isPlaced(b.name)));
|
|
959
|
+
for (const d of entries) {
|
|
960
|
+
ctx.refresh();
|
|
961
|
+
const s = path6.join(src, d.name);
|
|
962
|
+
const st = fs10.lstatSync(s);
|
|
963
|
+
const relName = join(rel, d.name);
|
|
964
|
+
const placed = rec?.placed.get(relName);
|
|
965
|
+
if (st.isDirectory()) {
|
|
966
|
+
let target = path6.join(dst, d.name);
|
|
967
|
+
if (placed && lstatOrNull(placed.target)?.isDirectory()) {
|
|
968
|
+
target = placed.target;
|
|
969
|
+
} else {
|
|
970
|
+
try {
|
|
971
|
+
fs10.mkdirSync(target, { mode: st.mode & 4095 | 448 });
|
|
972
|
+
} catch (e) {
|
|
973
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
974
|
+
if (!fs10.lstatSync(target).isDirectory()) {
|
|
975
|
+
const name2 = conflictName(dst, d.name, ctx);
|
|
976
|
+
target = path6.join(dst, name2);
|
|
977
|
+
fs10.mkdirSync(target, { mode: st.mode & 4095 | 448 });
|
|
978
|
+
stats.conflicts.push(join(rel, name2));
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
moveTree(s, target, ctx, stats, rec, root, relName);
|
|
983
|
+
fs10.rmdirSync(s);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (placed) {
|
|
987
|
+
if (stillPlaced(placed, s, st, root)) {
|
|
988
|
+
fs10.unlinkSync(s);
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
if (st.isFile() && !placed.identical && rec && takeOver(s, placed, rec, dst, ctx, stats, rel)) {
|
|
992
|
+
stats.moved++;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (st.isSymbolicLink()) {
|
|
997
|
+
const link = linkText(s, root);
|
|
998
|
+
const to = path6.join(dst, d.name);
|
|
999
|
+
try {
|
|
1000
|
+
fs10.symlinkSync(link, to);
|
|
1001
|
+
fs10.unlinkSync(s);
|
|
1002
|
+
stats.moved++;
|
|
1003
|
+
continue;
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
1006
|
+
}
|
|
1007
|
+
} else if (moveNoClobber(s, path6.join(dst, d.name), st) === "moved") {
|
|
1008
|
+
stats.moved++;
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
if (identicalEntry(s, st, path6.join(dst, d.name), root)) {
|
|
1012
|
+
fs10.unlinkSync(s);
|
|
1013
|
+
stats.dropped++;
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
const name = conflictName(dst, d.name, ctx);
|
|
1017
|
+
if (st.isSymbolicLink()) {
|
|
1018
|
+
fs10.symlinkSync(linkText(s, root), path6.join(dst, name));
|
|
1019
|
+
fs10.unlinkSync(s);
|
|
1020
|
+
} else if (moveNoClobber(s, path6.join(dst, name), st) !== "moved") {
|
|
1021
|
+
throw new Error(`can't place ${relName}: ${name} is taken`);
|
|
1022
|
+
}
|
|
1023
|
+
stats.moved++;
|
|
1024
|
+
stats.conflicts.push(join(rel, name));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
function summarize(account, entry, storeLabel, stats) {
|
|
1028
|
+
if (stats.moved + stats.dropped === 0 && stats.conflicts.length === 0) return null;
|
|
1029
|
+
const parts = [`${stats.moved} moved`];
|
|
1030
|
+
if (stats.dropped) parts.push(`${stats.dropped} identical dropped`);
|
|
1031
|
+
let line = `xclaude: merged ${account}/${entry} into ${storeLabel}: ${parts.join(", ")}`;
|
|
1032
|
+
if (stats.conflicts.length) {
|
|
1033
|
+
const shown = stats.conflicts.slice(0, 3).join(", ");
|
|
1034
|
+
const more = stats.conflicts.length > 3 ? ` and ${stats.conflicts.length - 3} more` : "";
|
|
1035
|
+
line += `; ${stats.conflicts.length} conflict${stats.conflicts.length > 1 ? "s" : ""} kept as ${shown}${more}`;
|
|
1036
|
+
}
|
|
1037
|
+
return line;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/link/table.ts
|
|
1041
|
+
var HISTORY = "history.jsonl";
|
|
1042
|
+
var STUB = "CLAUDE.md";
|
|
1043
|
+
var SHARED_DIRS = [
|
|
1044
|
+
"projects",
|
|
1045
|
+
"sessions",
|
|
1046
|
+
"session-env",
|
|
1047
|
+
"file-history",
|
|
1048
|
+
"tasks",
|
|
1049
|
+
"todos",
|
|
1050
|
+
"teams",
|
|
1051
|
+
"plans",
|
|
1052
|
+
"paste-cache",
|
|
1053
|
+
"image-cache",
|
|
1054
|
+
"uploads",
|
|
1055
|
+
"downloads",
|
|
1056
|
+
"debug",
|
|
1057
|
+
"chrome",
|
|
1058
|
+
"skills",
|
|
1059
|
+
"agents",
|
|
1060
|
+
"commands",
|
|
1061
|
+
"rules",
|
|
1062
|
+
"output-styles",
|
|
1063
|
+
"themes",
|
|
1064
|
+
"workflows",
|
|
1065
|
+
"hooks",
|
|
1066
|
+
"agent-memory",
|
|
1067
|
+
"memory",
|
|
1068
|
+
"plugins"
|
|
1069
|
+
];
|
|
1070
|
+
var OPTIONAL_DIRS = /* @__PURE__ */ new Set(["memory"]);
|
|
1071
|
+
var NEVER_SHARED = [
|
|
1072
|
+
".claude.json",
|
|
1073
|
+
".claude.json.backup",
|
|
1074
|
+
".credentials.json",
|
|
1075
|
+
"settings.json",
|
|
1076
|
+
"settings.local.json",
|
|
1077
|
+
"keybindings.json",
|
|
1078
|
+
"remote-settings.json",
|
|
1079
|
+
"policy-limits.json",
|
|
1080
|
+
"jobs",
|
|
1081
|
+
"daemon"
|
|
1082
|
+
];
|
|
1083
|
+
var PER_ACCOUNT = [
|
|
1084
|
+
...NEVER_SHARED,
|
|
1085
|
+
"backups",
|
|
1086
|
+
"cache",
|
|
1087
|
+
"shell-snapshots",
|
|
1088
|
+
"stats-cache.json",
|
|
1089
|
+
"usage-data",
|
|
1090
|
+
"ide"
|
|
1091
|
+
];
|
|
1092
|
+
var APPENDIX_A = [
|
|
1093
|
+
".claude.json",
|
|
1094
|
+
".claude.json.backup",
|
|
1095
|
+
".credentials.json",
|
|
1096
|
+
"projects",
|
|
1097
|
+
"sessions",
|
|
1098
|
+
"todos",
|
|
1099
|
+
"shell-snapshots",
|
|
1100
|
+
"statsig",
|
|
1101
|
+
"file-history",
|
|
1102
|
+
"history.jsonl",
|
|
1103
|
+
"ide",
|
|
1104
|
+
"logs",
|
|
1105
|
+
"backups",
|
|
1106
|
+
".session_ingress_token",
|
|
1107
|
+
"policy-limits.json",
|
|
1108
|
+
"remote-settings.json",
|
|
1109
|
+
"hfi-auth.json",
|
|
1110
|
+
"daemon",
|
|
1111
|
+
"jobs",
|
|
1112
|
+
"teams",
|
|
1113
|
+
"usage-data",
|
|
1114
|
+
"shares",
|
|
1115
|
+
"state",
|
|
1116
|
+
"uploads",
|
|
1117
|
+
"feedback",
|
|
1118
|
+
"feedback-bundles",
|
|
1119
|
+
"plans",
|
|
1120
|
+
"telemetry",
|
|
1121
|
+
"dump-prompts",
|
|
1122
|
+
"debug",
|
|
1123
|
+
"traces",
|
|
1124
|
+
"startup-perf",
|
|
1125
|
+
"cache",
|
|
1126
|
+
"mcp-discovery-cache",
|
|
1127
|
+
"mcp-needs-auth-cache.json",
|
|
1128
|
+
"gh-pr-status-cache.json",
|
|
1129
|
+
"tasks",
|
|
1130
|
+
"local",
|
|
1131
|
+
"antproto.json",
|
|
1132
|
+
"ccr",
|
|
1133
|
+
"session-env",
|
|
1134
|
+
"bridge-spawn",
|
|
1135
|
+
"active-time.json",
|
|
1136
|
+
"loop.md",
|
|
1137
|
+
"server-sessions.json",
|
|
1138
|
+
"image-cache",
|
|
1139
|
+
"paste-cache",
|
|
1140
|
+
"file-transfers",
|
|
1141
|
+
"mcp-skill-archives",
|
|
1142
|
+
"stats-cache.json",
|
|
1143
|
+
"computer-use.lock",
|
|
1144
|
+
"server.lock",
|
|
1145
|
+
"api-dumps",
|
|
1146
|
+
"chrome",
|
|
1147
|
+
"downloads",
|
|
1148
|
+
"local-settings",
|
|
1149
|
+
"project-settings",
|
|
1150
|
+
"remote",
|
|
1151
|
+
"scratch",
|
|
1152
|
+
"seed-admin",
|
|
1153
|
+
"storage-v2",
|
|
1154
|
+
"systemd"
|
|
1155
|
+
];
|
|
1156
|
+
var APPENDIX_A_PATTERNS = [/^daemon\.log/, /^policy-limits\.json\.stamp\.json$/];
|
|
1157
|
+
function shareTable(config, switches) {
|
|
1158
|
+
const off = new Set(config.share.remove);
|
|
1159
|
+
if (!switches.shareSessions) off.add("sessions");
|
|
1160
|
+
if (!switches.shareSkills) off.add("skills");
|
|
1161
|
+
if (!switches.shareChrome) off.add("chrome");
|
|
1162
|
+
const dirs = SHARED_DIRS.filter((e) => !off.has(e));
|
|
1163
|
+
if (switches.linkIde && !off.has("ide")) dirs.push("ide");
|
|
1164
|
+
const refused = [];
|
|
1165
|
+
for (const name of config.share.add) {
|
|
1166
|
+
if (!isShareableDirName(name)) refused.push(name);
|
|
1167
|
+
else if (!dirs.includes(name) && !off.has(name)) dirs.push(name);
|
|
1168
|
+
}
|
|
1169
|
+
return {
|
|
1170
|
+
dirs,
|
|
1171
|
+
history: switches.shareHistory && !off.has(HISTORY),
|
|
1172
|
+
stub: !off.has(STUB),
|
|
1173
|
+
refused
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function isShareableDirName(name) {
|
|
1177
|
+
const lower = name.toLowerCase();
|
|
1178
|
+
const perAccount = [...PER_ACCOUNT, ...APPENDIX_A, HISTORY, STUB].filter((n) => !SHARED_DIRS.includes(n)).map((n) => n.toLowerCase());
|
|
1179
|
+
if (perAccount.includes(lower)) return false;
|
|
1180
|
+
return !name.startsWith(".") && !/\.(json|jsonl|md|txt|log|lock|yaml|yml|toml|db|sqlite)$/i.test(name);
|
|
1181
|
+
}
|
|
1182
|
+
function isIgnored(name) {
|
|
1183
|
+
return name.endsWith(".lock") || name.startsWith(".xclaude-") || name === ".DS_Store" || /\.tmp(?:[.-]|$)/.test(name);
|
|
1184
|
+
}
|
|
1185
|
+
function isKnown(name, table) {
|
|
1186
|
+
return table.dirs.includes(name) || name === HISTORY || name === STUB || SHARED_DIRS.includes(name) || PER_ACCOUNT.includes(name) || APPENDIX_A.includes(name) || APPENDIX_A_PATTERNS.some((re) => re.test(name));
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/link/history.ts
|
|
1190
|
+
function timestampOf(line) {
|
|
1191
|
+
let entry;
|
|
1192
|
+
try {
|
|
1193
|
+
entry = JSON.parse(line);
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
if (typeof entry !== "object" || entry === null) return null;
|
|
1198
|
+
const ts = entry.timestamp;
|
|
1199
|
+
if (typeof ts === "number" && Number.isFinite(ts)) return ts;
|
|
1200
|
+
if (typeof ts === "string") {
|
|
1201
|
+
const ms = Date.parse(ts);
|
|
1202
|
+
return Number.isNaN(ms) ? null : ms;
|
|
1203
|
+
}
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
function mergeHistoryLines(store, account) {
|
|
1207
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1208
|
+
const items = [];
|
|
1209
|
+
[store, account].forEach((lines, src) => {
|
|
1210
|
+
let key = -Infinity;
|
|
1211
|
+
lines.forEach((line, idx) => {
|
|
1212
|
+
if (line.trim() === "" || seen.has(line)) return;
|
|
1213
|
+
seen.add(line);
|
|
1214
|
+
key = timestampOf(line) ?? key;
|
|
1215
|
+
items.push({ line, key, src, idx });
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
items.sort((a, b) => a.key - b.key || a.src - b.src || a.idx - b.idx);
|
|
1219
|
+
return items.map((i) => i.line);
|
|
1220
|
+
}
|
|
1221
|
+
function readLines(file) {
|
|
1222
|
+
return (readFileOrNull(file) ?? "").split("\n");
|
|
1223
|
+
}
|
|
1224
|
+
function storeLockPath(S) {
|
|
1225
|
+
return `${realpathOrNull(path7.join(S, HISTORY)) ?? path7.join(S, HISTORY)}.lock`;
|
|
1226
|
+
}
|
|
1227
|
+
function mergeAsideIntoStore(ctx, aside, alsoHeld) {
|
|
1228
|
+
const refreshAll = () => {
|
|
1229
|
+
ctx.refreshStoreLock();
|
|
1230
|
+
alsoHeld?.refresh();
|
|
1231
|
+
};
|
|
1232
|
+
const waitMs = alsoHeld ? Math.min(ctx.lockWaitMs, 5e3) : ctx.lockWaitMs;
|
|
1233
|
+
const storeLock = DirLock.acquire(storeLockPath(ctx.S), HISTORY_LOCK, { waitMs, onWait: refreshAll });
|
|
1234
|
+
if (!storeLock) return { ok: false, reason: "the shared history.jsonl is busy" };
|
|
1235
|
+
try {
|
|
1236
|
+
const storeFile = realpathOrNull(path7.join(ctx.S, HISTORY));
|
|
1237
|
+
if (!storeFile) return { ok: false, reason: "~/.claude/history.jsonl doesn't resolve" };
|
|
1238
|
+
const storeLines = readLines(storeFile);
|
|
1239
|
+
const merged = mergeHistoryLines(storeLines, readLines(aside));
|
|
1240
|
+
refreshAll();
|
|
1241
|
+
if (!storeLock.refresh() || alsoHeld && !alsoHeld.refresh()) return { ok: false, reason: "a history lock was taken over" };
|
|
1242
|
+
writeFileAtomic(storeFile, merged.length ? `${merged.join("\n")}
|
|
1243
|
+
` : "", { mode: 384, fsync: true });
|
|
1244
|
+
fs11.unlinkSync(aside);
|
|
1245
|
+
const before = new Set(storeLines);
|
|
1246
|
+
return { ok: true, added: merged.filter((line) => !before.has(line)).length };
|
|
1247
|
+
} finally {
|
|
1248
|
+
storeLock.release();
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
function mergeHistory(ctx) {
|
|
1252
|
+
const file = path7.join(ctx.A, HISTORY);
|
|
1253
|
+
const accountLock = DirLock.acquire(`${file}.lock`, HISTORY_LOCK, { waitMs: ctx.lockWaitMs, onWait: ctx.refreshStoreLock });
|
|
1254
|
+
if (!accountLock) return { ok: false, reason: `this account's history.jsonl is busy` };
|
|
1255
|
+
try {
|
|
1256
|
+
const st = lstatOrNull(file);
|
|
1257
|
+
if (!st?.isFile()) return { ok: true, added: 0 };
|
|
1258
|
+
const storeFile = realpathOrNull(path7.join(ctx.S, HISTORY));
|
|
1259
|
+
if (!storeFile) return { ok: false, reason: "~/.claude/history.jsonl doesn't resolve" };
|
|
1260
|
+
const aside = path7.join(ctx.A, asideName(HISTORY));
|
|
1261
|
+
fs11.linkSync(file, aside);
|
|
1262
|
+
const tmp = siblingName(file, "link");
|
|
1263
|
+
fs11.symlinkSync(storeFile, tmp);
|
|
1264
|
+
fs11.renameSync(tmp, file);
|
|
1265
|
+
return mergeAsideIntoStore(ctx, aside, accountLock);
|
|
1266
|
+
} finally {
|
|
1267
|
+
accountLock.release();
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
function resumeHistoryAside(ctx, aside, shared) {
|
|
1271
|
+
const file = path7.join(ctx.A, HISTORY);
|
|
1272
|
+
const st = lstatOrNull(file);
|
|
1273
|
+
const asideStat = fs11.lstatSync(aside);
|
|
1274
|
+
if (st?.isFile() && st.ino === asideStat.ino && st.dev === asideStat.dev) {
|
|
1275
|
+
fs11.unlinkSync(aside);
|
|
1276
|
+
return { ok: true, added: 0 };
|
|
1277
|
+
}
|
|
1278
|
+
if (!shared) return mergeAsideIntoAccount(ctx, aside);
|
|
1279
|
+
return mergeAsideIntoStore(ctx, aside, null);
|
|
1280
|
+
}
|
|
1281
|
+
function mergeAsideIntoAccount(ctx, aside) {
|
|
1282
|
+
const file = path7.join(ctx.A, HISTORY);
|
|
1283
|
+
const lock = DirLock.acquire(`${file}.lock`, HISTORY_LOCK, { waitMs: ctx.lockWaitMs, onWait: ctx.refreshStoreLock });
|
|
1284
|
+
if (!lock) return { ok: false, reason: "this account's history.jsonl is busy" };
|
|
1285
|
+
try {
|
|
1286
|
+
const st = lstatOrNull(file);
|
|
1287
|
+
if (st && !st.isFile()) return { ok: false, reason: "this account's history.jsonl isn't a regular file" };
|
|
1288
|
+
const own = readLines(file);
|
|
1289
|
+
const merged = mergeHistoryLines(own, readLines(aside));
|
|
1290
|
+
if (!lock.refresh()) return { ok: false, reason: "a history lock was taken over" };
|
|
1291
|
+
writeFileAtomic(file, merged.length ? `${merged.join("\n")}
|
|
1292
|
+
` : "", { mode: 384, fsync: true });
|
|
1293
|
+
fs11.unlinkSync(aside);
|
|
1294
|
+
const before = new Set(own);
|
|
1295
|
+
return { ok: true, added: merged.filter((line) => !before.has(line)).length };
|
|
1296
|
+
} finally {
|
|
1297
|
+
lock.release();
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/link/stub.ts
|
|
1302
|
+
import fs12 from "node:fs";
|
|
1303
|
+
import path8 from "node:path";
|
|
1304
|
+
var STUB_TEXT = "Shared instructions live in ~/.claude/CLAUDE.md, so edit that file.\n@~/.claude/CLAUDE.md\n";
|
|
1305
|
+
function stubState(A, S, enabled, detail) {
|
|
1306
|
+
const storeHas = enabled && Boolean(statOrNull(path8.join(S, STUB))?.isFile());
|
|
1307
|
+
const a = lstatOrNull(path8.join(A, STUB));
|
|
1308
|
+
if (!a) return storeHas ? "create" : "none";
|
|
1309
|
+
if (storeHas && !detail) return "ok";
|
|
1310
|
+
const isStub = a.isFile() && readFileOrNull(path8.join(A, STUB)) === STUB_TEXT;
|
|
1311
|
+
if (storeHas) return isStub ? "ok" : "differs";
|
|
1312
|
+
return isStub ? "remove" : "differs";
|
|
1313
|
+
}
|
|
1314
|
+
function applyStub(A, state) {
|
|
1315
|
+
const file = path8.join(A, STUB);
|
|
1316
|
+
if (state === "create") {
|
|
1317
|
+
try {
|
|
1318
|
+
fs12.writeFileSync(file, STUB_TEXT, { flag: "wx", mode: 384 });
|
|
1319
|
+
return "created";
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
if (errCode(e) === "EEXIST") return null;
|
|
1322
|
+
throw e;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (state === "remove" && readFileOrNull(file) === STUB_TEXT) {
|
|
1326
|
+
fs12.unlinkSync(file);
|
|
1327
|
+
return "removed";
|
|
1328
|
+
}
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// src/link/repair.ts
|
|
1333
|
+
function problemLine(account, p) {
|
|
1334
|
+
return `${account}${p.entry ? `/${p.entry}` : "'s folder"} ${p.message}`;
|
|
1335
|
+
}
|
|
1336
|
+
var LAUNCH_LOCK_WAIT_MS = 2e3;
|
|
1337
|
+
function storeKind(p) {
|
|
1338
|
+
if (!lstatOrNull(p)) return "missing";
|
|
1339
|
+
const st = statOrNull(p);
|
|
1340
|
+
if (!st) return "other";
|
|
1341
|
+
return st.isDirectory() ? "dir" : st.isFile() ? "file" : "other";
|
|
1342
|
+
}
|
|
1343
|
+
function classifyEntry(A, S, name, kind) {
|
|
1344
|
+
const a = path9.join(A, name);
|
|
1345
|
+
const s = path9.join(S, name);
|
|
1346
|
+
const lst = lstatOrNull(a);
|
|
1347
|
+
const optional = OPTIONAL_DIRS.has(name);
|
|
1348
|
+
const sk = storeKind(s);
|
|
1349
|
+
const storeOk = sk === "missing" || sk === kind;
|
|
1350
|
+
if (!lst) {
|
|
1351
|
+
if (optional && sk === "missing") return { name, kind, state: "optional-absent" };
|
|
1352
|
+
return { name, kind, state: storeOk ? "missing" : "store-wrong-type" };
|
|
1353
|
+
}
|
|
1354
|
+
if (lst.isSymbolicLink()) {
|
|
1355
|
+
const target = readlinkOrNull(a);
|
|
1356
|
+
if (target === null) return { name, kind, state: "missing" };
|
|
1357
|
+
if (target === s || target === realpathOrNull(s)) {
|
|
1358
|
+
if (sk === kind) return { name, kind, state: "ok", target };
|
|
1359
|
+
if (sk === "missing") return { name, kind, state: optional ? "unshare" : "store-missing", target };
|
|
1360
|
+
return { name, kind, state: "store-wrong-type", target };
|
|
1361
|
+
}
|
|
1362
|
+
return { name, kind, state: "link-elsewhere", target };
|
|
1363
|
+
}
|
|
1364
|
+
if (kind === "dir" && lst.isDirectory()) {
|
|
1365
|
+
if (optional && sk === "missing") return { name, kind, state: "optional-absent" };
|
|
1366
|
+
return { name, kind, state: storeOk ? "real-dir" : "store-wrong-type" };
|
|
1367
|
+
}
|
|
1368
|
+
if (kind === "file" && lst.isFile()) return { name, kind, state: storeOk ? "real-file" : "store-wrong-type" };
|
|
1369
|
+
return { name, kind, state: "wrong-type" };
|
|
1370
|
+
}
|
|
1371
|
+
function inspectAccount(paths, account, table, detail = false) {
|
|
1372
|
+
const A = accountDir(paths, account);
|
|
1373
|
+
const S = paths.store;
|
|
1374
|
+
let dirents;
|
|
1375
|
+
try {
|
|
1376
|
+
dirents = fs13.readdirSync(A, { withFileTypes: true });
|
|
1377
|
+
} catch (e) {
|
|
1378
|
+
if (errCode(e) !== "ENOENT") throw e;
|
|
1379
|
+
return { dir: A, exists: false, entries: [], leftovers: [], stub: "none", names: [] };
|
|
1380
|
+
}
|
|
1381
|
+
const entries = table.dirs.map((name) => classifyEntry(A, S, name, "dir"));
|
|
1382
|
+
if (table.history) entries.push(classifyEntry(A, S, HISTORY, "file"));
|
|
1383
|
+
const shared = new Set(entries.map((e) => e.name));
|
|
1384
|
+
for (const d of dirents) {
|
|
1385
|
+
const name = d.name;
|
|
1386
|
+
if (!d.isSymbolicLink() || shared.has(name) || name === STUB || NEVER_SHARED.includes(name) || isIgnored(name)) continue;
|
|
1387
|
+
const target = readlinkOrNull(path9.join(A, name));
|
|
1388
|
+
const s = path9.join(S, name);
|
|
1389
|
+
if (target === null || target !== s && target !== realpathOrNull(s)) continue;
|
|
1390
|
+
const kind = name === HISTORY || storeKind(s) === "file" ? "file" : "dir";
|
|
1391
|
+
entries.push({ name, kind, state: "unshare", target });
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
dir: A,
|
|
1395
|
+
exists: true,
|
|
1396
|
+
entries,
|
|
1397
|
+
leftovers: dirents.map((d) => d.name).filter((n) => n.startsWith(".xclaude-")),
|
|
1398
|
+
stub: stubState(A, S, table.stub, detail),
|
|
1399
|
+
names: dirents.map((d) => d.name)
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
function needsWork(insp, opts) {
|
|
1403
|
+
if (!insp.exists || insp.leftovers.length > 0 || insp.stub === "create" || insp.stub === "remove") return true;
|
|
1404
|
+
return insp.entries.some((e) => {
|
|
1405
|
+
switch (e.state) {
|
|
1406
|
+
case "missing":
|
|
1407
|
+
case "store-missing":
|
|
1408
|
+
case "real-dir":
|
|
1409
|
+
case "real-file":
|
|
1410
|
+
case "unshare":
|
|
1411
|
+
return true;
|
|
1412
|
+
case "link-elsewhere":
|
|
1413
|
+
return opts.replaceWrongLinks;
|
|
1414
|
+
default:
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
function describeProblems(insp, paths) {
|
|
1420
|
+
const S = tildify(paths.store, paths.home);
|
|
1421
|
+
const problems = [];
|
|
1422
|
+
for (const e of insp.entries) {
|
|
1423
|
+
const where = `${S}/${e.name}`;
|
|
1424
|
+
let message = null;
|
|
1425
|
+
switch (e.state) {
|
|
1426
|
+
case "link-elsewhere":
|
|
1427
|
+
message = `links to ${e.target}, not ${where}; \`xclaude doctor --fix\` replaces the link`;
|
|
1428
|
+
break;
|
|
1429
|
+
case "wrong-type":
|
|
1430
|
+
message = `is a ${e.kind === "dir" ? "file" : "directory"} where a ${e.kind === "dir" ? "directory" : "file"} link belongs; move it away, then run \`xclaude doctor --fix\``;
|
|
1431
|
+
break;
|
|
1432
|
+
case "store-wrong-type":
|
|
1433
|
+
message = `${where} isn't a ${e.kind === "dir" ? "directory" : "file"} (or doesn't resolve), so it can't be shared`;
|
|
1434
|
+
break;
|
|
1435
|
+
case "missing":
|
|
1436
|
+
case "store-missing":
|
|
1437
|
+
case "real-dir":
|
|
1438
|
+
case "real-file":
|
|
1439
|
+
case "unshare":
|
|
1440
|
+
message = "not repaired yet; run `xclaude doctor --fix`";
|
|
1441
|
+
break;
|
|
1442
|
+
default:
|
|
1443
|
+
break;
|
|
1444
|
+
}
|
|
1445
|
+
if (message) problems.push({ entry: e.name, message });
|
|
1446
|
+
}
|
|
1447
|
+
for (const name of insp.leftovers) {
|
|
1448
|
+
problems.push({ entry: name, message: "left by an interrupted repair; `xclaude doctor --fix` resumes it" });
|
|
1449
|
+
}
|
|
1450
|
+
return problems;
|
|
1451
|
+
}
|
|
1452
|
+
function ensureStoreEntry(S, name, kind) {
|
|
1453
|
+
fs13.mkdirSync(S, { recursive: true, mode: 448 });
|
|
1454
|
+
const p = path9.join(S, name);
|
|
1455
|
+
try {
|
|
1456
|
+
if (kind === "dir") fs13.mkdirSync(p, { mode: 448 });
|
|
1457
|
+
else fs13.closeSync(fs13.openSync(p, "wx", 384));
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
var Repairer = class {
|
|
1463
|
+
A;
|
|
1464
|
+
S;
|
|
1465
|
+
opts;
|
|
1466
|
+
lock;
|
|
1467
|
+
problems = [];
|
|
1468
|
+
/** The store lock was taken over: stop, and leave the rest to the next run. */
|
|
1469
|
+
lost = false;
|
|
1470
|
+
constructor(opts, lock) {
|
|
1471
|
+
this.opts = opts;
|
|
1472
|
+
this.lock = lock;
|
|
1473
|
+
this.A = accountDir(opts.paths, opts.account);
|
|
1474
|
+
this.S = opts.paths.store;
|
|
1475
|
+
}
|
|
1476
|
+
get label() {
|
|
1477
|
+
return this.opts.account;
|
|
1478
|
+
}
|
|
1479
|
+
storePath(name) {
|
|
1480
|
+
return `${tildify(this.S, this.opts.paths.home)}/${name}`;
|
|
1481
|
+
}
|
|
1482
|
+
problem(entry, message) {
|
|
1483
|
+
this.problems.push({ entry, message });
|
|
1484
|
+
}
|
|
1485
|
+
run(insp) {
|
|
1486
|
+
for (const info of insp.entries) this.guard(info.name, () => this.act(info, 0));
|
|
1487
|
+
for (const name of insp.leftovers) this.guard(name, () => this.resumeLeftover(name));
|
|
1488
|
+
this.guard(STUB, () => applyStub(this.A, insp.stub));
|
|
1489
|
+
}
|
|
1490
|
+
/** Runs one step; an unexpected error becomes a problem instead of stopping the launch. */
|
|
1491
|
+
guard(entry, step2) {
|
|
1492
|
+
if (this.lost || !this.lock.refresh()) {
|
|
1493
|
+
this.lost = true;
|
|
1494
|
+
this.problem(entry, "skipped: another xclaude took over the repair lock; the next run finishes it");
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
try {
|
|
1498
|
+
step2();
|
|
1499
|
+
} catch (e) {
|
|
1500
|
+
if (e instanceof LockLost) {
|
|
1501
|
+
this.lost = true;
|
|
1502
|
+
this.problem(entry, "stopped: another xclaude took over the repair lock; the next run finishes it");
|
|
1503
|
+
} else {
|
|
1504
|
+
this.problem(entry, `repair failed: ${e.message}`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
act(info, depth) {
|
|
1509
|
+
switch (info.state) {
|
|
1510
|
+
case "missing":
|
|
1511
|
+
this.createLink(info, depth);
|
|
1512
|
+
return;
|
|
1513
|
+
case "store-missing":
|
|
1514
|
+
ensureStoreEntry(this.S, info.name, info.kind);
|
|
1515
|
+
return;
|
|
1516
|
+
case "link-elsewhere":
|
|
1517
|
+
if (this.opts.replaceWrongLinks) this.replaceLink(info);
|
|
1518
|
+
return;
|
|
1519
|
+
case "unshare":
|
|
1520
|
+
this.unshare(info);
|
|
1521
|
+
return;
|
|
1522
|
+
case "real-dir":
|
|
1523
|
+
this.mergeDir(info);
|
|
1524
|
+
return;
|
|
1525
|
+
case "real-file":
|
|
1526
|
+
this.mergeHistory();
|
|
1527
|
+
return;
|
|
1528
|
+
default:
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
/** realpath(S/E), created first if missing. Never a path inside an account dir. */
|
|
1533
|
+
linkTarget(info) {
|
|
1534
|
+
ensureStoreEntry(this.S, info.name, info.kind);
|
|
1535
|
+
const target = realpathOrNull(path9.join(this.S, info.name));
|
|
1536
|
+
if (!target) throw new Error(`${this.storePath(info.name)} doesn't resolve`);
|
|
1537
|
+
const accounts = realpathOrNull(this.opts.paths.accounts) ?? this.opts.paths.accounts;
|
|
1538
|
+
if (target === accounts || target.startsWith(`${accounts}${path9.sep}`)) {
|
|
1539
|
+
throw new Error(`${this.storePath(info.name)} resolves into ${tildify(target, this.opts.paths.home)}, inside an account dir; make it a real ${info.kind === "dir" ? "directory" : "file"} again`);
|
|
1540
|
+
}
|
|
1541
|
+
return target;
|
|
1542
|
+
}
|
|
1543
|
+
createLink(info, depth) {
|
|
1544
|
+
const target = this.linkTarget(info);
|
|
1545
|
+
try {
|
|
1546
|
+
fs13.symlinkSync(target, path9.join(this.A, info.name));
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
if (errCode(e) !== "EEXIST" || depth > 0) throw e;
|
|
1549
|
+
this.act(classifyEntry(this.A, this.S, info.name, info.kind), depth + 1);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
replaceLink(info) {
|
|
1553
|
+
const target = this.linkTarget(info);
|
|
1554
|
+
const a = path9.join(this.A, info.name);
|
|
1555
|
+
const tmp = siblingName(a, "link");
|
|
1556
|
+
fs13.symlinkSync(target, tmp);
|
|
1557
|
+
fs13.renameSync(tmp, a);
|
|
1558
|
+
this.opts.log(`xclaude: ${this.label}/${info.name} pointed to ${info.target}; relinked to ${this.storePath(info.name)}`);
|
|
1559
|
+
}
|
|
1560
|
+
unshare(info) {
|
|
1561
|
+
const a = path9.join(this.A, info.name);
|
|
1562
|
+
if (info.kind === "file") {
|
|
1563
|
+
const tmp = siblingName(a, "unshare");
|
|
1564
|
+
fs13.closeSync(fs13.openSync(tmp, "wx", 384));
|
|
1565
|
+
fs13.renameSync(tmp, a);
|
|
1566
|
+
} else {
|
|
1567
|
+
fs13.unlinkSync(a);
|
|
1568
|
+
try {
|
|
1569
|
+
fs13.mkdirSync(a, { mode: 448 });
|
|
1570
|
+
} catch (e) {
|
|
1571
|
+
if (errCode(e) !== "EEXIST") throw e;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
this.opts.log(
|
|
1575
|
+
`xclaude: ${this.label}/${info.name} is no longer shared and is now per account; the shared copy stays in ${this.storePath(info.name)}`
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
moveContext() {
|
|
1579
|
+
return {
|
|
1580
|
+
account: this.label,
|
|
1581
|
+
stamp: stamp(),
|
|
1582
|
+
refresh: () => {
|
|
1583
|
+
if (!this.lock.refresh()) throw new LockLost();
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Directory merge, link first: every file gets its store link while
|
|
1589
|
+
* the real directory is still in place (passes repeat until nothing new
|
|
1590
|
+
* appears), then the directory is swapped for the link, then the aside is
|
|
1591
|
+
* emptied. A failure before the swap is undone completely, unless the lock
|
|
1592
|
+
* was lost: then another process may already rely on those links.
|
|
1593
|
+
*/
|
|
1594
|
+
mergeDir(info) {
|
|
1595
|
+
const a = path9.join(this.A, info.name);
|
|
1596
|
+
const target = this.linkTarget(info);
|
|
1597
|
+
const ctx = this.moveContext();
|
|
1598
|
+
const stats = emptyStats();
|
|
1599
|
+
const rec = newRecord();
|
|
1600
|
+
let start;
|
|
1601
|
+
try {
|
|
1602
|
+
start = requireRealDir(a);
|
|
1603
|
+
for (let pass = 0; pass < 5; pass++) {
|
|
1604
|
+
if (linkTree(a, target, ctx, stats, rec) === 0) break;
|
|
1605
|
+
}
|
|
1606
|
+
ctx.refresh();
|
|
1607
|
+
const now = lstatOrNull(a);
|
|
1608
|
+
if (!now?.isDirectory() || now.ino !== start.ino || now.dev !== start.dev) {
|
|
1609
|
+
this.problem(info.name, "changed while being merged; the next run finishes the job");
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
} catch (e) {
|
|
1613
|
+
if (e instanceof LockLost) throw e;
|
|
1614
|
+
undoLinks(rec);
|
|
1615
|
+
this.cannotMerge(info.name, e);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
const asides = [];
|
|
1619
|
+
for (let attempt = 0; ; attempt++) {
|
|
1620
|
+
const aside = path9.join(this.A, asideName(info.name));
|
|
1621
|
+
fs13.renameSync(a, aside);
|
|
1622
|
+
asides.push(aside);
|
|
1623
|
+
try {
|
|
1624
|
+
fs13.symlinkSync(target, a);
|
|
1625
|
+
break;
|
|
1626
|
+
} catch (e) {
|
|
1627
|
+
if (errCode(e) !== "EEXIST") {
|
|
1628
|
+
fs13.renameSync(asides[0], a);
|
|
1629
|
+
if (asides.length === 1) undoLinks(rec);
|
|
1630
|
+
throw e;
|
|
1631
|
+
}
|
|
1632
|
+
const now = classifyEntry(this.A, this.S, info.name, "dir");
|
|
1633
|
+
if (now.state === "ok") break;
|
|
1634
|
+
if (now.state !== "real-dir" || attempt >= 3) throw new Error(`${info.name} kept changing while being merged`);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
asides.forEach((aside, i) => {
|
|
1638
|
+
moveTree(aside, target, ctx, stats, i === 0 ? rec : null);
|
|
1639
|
+
fs13.rmdirSync(aside);
|
|
1640
|
+
});
|
|
1641
|
+
const line = summarize(this.label, info.name, this.storePath(info.name), stats);
|
|
1642
|
+
if (line) this.opts.log(line);
|
|
1643
|
+
}
|
|
1644
|
+
cannotMerge(entry, e) {
|
|
1645
|
+
const code = errCode(e);
|
|
1646
|
+
if (code === "EXDEV") {
|
|
1647
|
+
this.problem(
|
|
1648
|
+
entry,
|
|
1649
|
+
`can't be merged: ${tildify(this.S, this.opts.paths.home)} and ${tildify(this.A, this.opts.paths.home)} are on different filesystems (see \`xclaude doctor\`)`
|
|
1650
|
+
);
|
|
1651
|
+
} else {
|
|
1652
|
+
this.problem(
|
|
1653
|
+
entry,
|
|
1654
|
+
`can't be merged, left as it was: ${e.message}${code === "EACCES" || code === "EPERM" || code === "EROFS" ? `; fix the permissions, or stop sharing ${entry} with share.remove` : ""}`
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
historyContext() {
|
|
1659
|
+
const lockWaitMs = this.opts.lockWaitMs === Infinity ? HISTORY_LOCK.staleMs + 2e3 : LAUNCH_LOCK_WAIT_MS;
|
|
1660
|
+
return { A: this.A, S: this.S, lockWaitMs, refreshStoreLock: () => this.lock.refresh() };
|
|
1661
|
+
}
|
|
1662
|
+
/** History merge. */
|
|
1663
|
+
mergeHistory() {
|
|
1664
|
+
this.linkTarget({ name: HISTORY, kind: "file", state: "real-file" });
|
|
1665
|
+
const res = mergeHistory(this.historyContext());
|
|
1666
|
+
if (!res.ok) {
|
|
1667
|
+
this.problem(HISTORY, `not merged yet (${res.reason}); the next launch retries`);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (res.added) {
|
|
1671
|
+
this.opts.log(`xclaude: merged ${this.label}'s prompt history into ${this.storePath(HISTORY)}: ${res.added} entries added`);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
resumeLeftover(name) {
|
|
1675
|
+
const p = path9.join(this.A, name);
|
|
1676
|
+
if (name.startsWith(".xclaude-link-") || name.startsWith(".xclaude-unshare-") || name.startsWith(".xclaude-tmp-")) {
|
|
1677
|
+
const st2 = lstatOrNull(p);
|
|
1678
|
+
if (st2?.isDirectory()) fs13.rmdirSync(p);
|
|
1679
|
+
else if (st2) fs13.unlinkSync(p);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const entry = parseAside(name);
|
|
1683
|
+
if (entry === null) {
|
|
1684
|
+
this.problem(name, "left by an interrupted repair; remove it by hand if it's empty");
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
const st = lstatOrNull(p);
|
|
1688
|
+
if (!st) return;
|
|
1689
|
+
if (st.isSymbolicLink()) {
|
|
1690
|
+
fs13.unlinkSync(p);
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
if (entry === HISTORY ? !st.isFile() : !st.isDirectory()) {
|
|
1694
|
+
this.problem(name, "left by an interrupted repair, but of an unexpected type; look at it by hand");
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
if (entry === HISTORY) {
|
|
1698
|
+
const res = resumeHistoryAside(this.historyContext(), p, this.opts.table.history);
|
|
1699
|
+
if (!res.ok) this.problem(name, `interrupted history merge not finished (${res.reason})`);
|
|
1700
|
+
else if (res.added) this.opts.log(`xclaude: finished merging ${this.label}'s prompt history: ${res.added} entries added`);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (!this.opts.table.dirs.includes(entry)) {
|
|
1704
|
+
const own = path9.join(this.A, entry);
|
|
1705
|
+
if (!lstatOrNull(own)) fs13.mkdirSync(own, { mode: 448 });
|
|
1706
|
+
const st2 = fs13.lstatSync(own);
|
|
1707
|
+
if (!st2.isDirectory() || st2.isSymbolicLink()) {
|
|
1708
|
+
this.problem(name, `left by an interrupted merge of ${entry}; ${entry} isn't a directory, so move the contents back by hand`);
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
moveTree(p, own, this.moveContext(), emptyStats());
|
|
1712
|
+
fs13.rmdirSync(p);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const target = this.linkTarget({ name: entry, kind: "dir", state: "ok" });
|
|
1716
|
+
const stats = emptyStats();
|
|
1717
|
+
try {
|
|
1718
|
+
moveTree(p, target, this.moveContext(), stats);
|
|
1719
|
+
fs13.rmdirSync(p);
|
|
1720
|
+
} catch (e) {
|
|
1721
|
+
this.cannotMerge(entry, e);
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const line = summarize(this.label, entry, this.storePath(entry), stats);
|
|
1725
|
+
if (line) this.opts.log(line);
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
function unmanageable(A, store) {
|
|
1729
|
+
if (lstatOrNull(A)?.isSymbolicLink()) return "is a symlink; xclaude only manages real folders there, so it leaves this one alone";
|
|
1730
|
+
const realA = realpathOrNull(A);
|
|
1731
|
+
const realS = realpathOrNull(store);
|
|
1732
|
+
if (realA && realS && (realA === realS || realS.startsWith(`${realA}${path9.sep}`))) {
|
|
1733
|
+
return "is the shared store or holds it; xclaude leaves it alone";
|
|
1734
|
+
}
|
|
1735
|
+
return null;
|
|
1736
|
+
}
|
|
1737
|
+
function repairAccount(opts) {
|
|
1738
|
+
const A = accountDir(opts.paths, opts.account);
|
|
1739
|
+
const refusal = unmanageable(A, opts.paths.store);
|
|
1740
|
+
if (refusal) return { clean: false, busy: false, problems: [{ entry: "", message: refusal }], names: [] };
|
|
1741
|
+
let insp = inspectAccount(opts.paths, opts.account, opts.table);
|
|
1742
|
+
if (!needsWork(insp, opts)) {
|
|
1743
|
+
const problems = describeProblems(insp, opts.paths);
|
|
1744
|
+
return { clean: problems.length === 0, busy: false, problems, names: insp.names };
|
|
1745
|
+
}
|
|
1746
|
+
const lock = DirLock.acquire(path9.join(opts.paths.locks, "store"), STORE_LOCK, { waitMs: opts.lockWaitMs });
|
|
1747
|
+
if (!lock) {
|
|
1748
|
+
const problems = describeProblems(insp, opts.paths);
|
|
1749
|
+
return { clean: false, busy: true, problems, names: insp.names };
|
|
1750
|
+
}
|
|
1751
|
+
try {
|
|
1752
|
+
if (!insp.exists) {
|
|
1753
|
+
mkdirPrivate(A);
|
|
1754
|
+
opts.log(`xclaude: ${opts.account}'s config dir was missing and has been recreated; log in again with /login`);
|
|
1755
|
+
}
|
|
1756
|
+
insp = inspectAccount(opts.paths, opts.account, opts.table);
|
|
1757
|
+
const repairer = new Repairer(opts, lock);
|
|
1758
|
+
repairer.run(insp);
|
|
1759
|
+
insp = inspectAccount(opts.paths, opts.account, opts.table);
|
|
1760
|
+
const failed = new Set(repairer.problems.map((p) => p.entry));
|
|
1761
|
+
const problems = [...repairer.problems, ...describeProblems(insp, opts.paths).filter((p) => !failed.has(p.entry))];
|
|
1762
|
+
return { clean: problems.length === 0, busy: false, problems, names: insp.names };
|
|
1763
|
+
} finally {
|
|
1764
|
+
lock.release();
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// src/core/shellwords.ts
|
|
1769
|
+
function splitShellWords(input, home) {
|
|
1770
|
+
const words2 = [];
|
|
1771
|
+
let word = "";
|
|
1772
|
+
let inWord = false;
|
|
1773
|
+
let i = 0;
|
|
1774
|
+
const atWordStart = () => !inWord;
|
|
1775
|
+
while (i < input.length) {
|
|
1776
|
+
const c = input[i];
|
|
1777
|
+
if (/\s/.test(c)) {
|
|
1778
|
+
if (inWord) words2.push(word);
|
|
1779
|
+
word = "";
|
|
1780
|
+
inWord = false;
|
|
1781
|
+
i++;
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
if (c === "~" && atWordStart() && (i + 1 === input.length || input[i + 1] === "/" || /\s/.test(input[i + 1]))) {
|
|
1785
|
+
word += home;
|
|
1786
|
+
inWord = true;
|
|
1787
|
+
i++;
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
inWord = true;
|
|
1791
|
+
if (c === "'") {
|
|
1792
|
+
const end = input.indexOf("'", i + 1);
|
|
1793
|
+
if (end < 0) throw new UsageError("unterminated ' in --args");
|
|
1794
|
+
word += input.slice(i + 1, end);
|
|
1795
|
+
i = end + 1;
|
|
1796
|
+
} else if (c === '"') {
|
|
1797
|
+
i++;
|
|
1798
|
+
for (; ; ) {
|
|
1799
|
+
if (i >= input.length) throw new UsageError('unterminated " in --args');
|
|
1800
|
+
const d = input[i];
|
|
1801
|
+
if (d === '"') {
|
|
1802
|
+
i++;
|
|
1803
|
+
break;
|
|
1804
|
+
}
|
|
1805
|
+
if (d === "\\" && i + 1 < input.length && '"\\$`\n'.includes(input[i + 1])) {
|
|
1806
|
+
word += input[i + 1];
|
|
1807
|
+
i += 2;
|
|
1808
|
+
} else {
|
|
1809
|
+
word += d;
|
|
1810
|
+
i++;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
} else if (c === "\\") {
|
|
1814
|
+
if (i + 1 < input.length) word += input[i + 1];
|
|
1815
|
+
i += 2;
|
|
1816
|
+
} else {
|
|
1817
|
+
word += c;
|
|
1818
|
+
i++;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (inWord) words2.push(word);
|
|
1822
|
+
return words2;
|
|
1823
|
+
}
|
|
1824
|
+
function quoteShellWord(word) {
|
|
1825
|
+
if (word !== "" && !word.startsWith("=") && /^[A-Za-z0-9_\-+=.,/:@%^]+$/.test(word)) return word;
|
|
1826
|
+
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1827
|
+
}
|
|
1828
|
+
function quoteShellWords(words2) {
|
|
1829
|
+
return words2.map(quoteShellWord).join(" ");
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// src/core/format.ts
|
|
1833
|
+
function formatTable(headers, rows) {
|
|
1834
|
+
const all = [headers, ...rows];
|
|
1835
|
+
const widths = headers.map((_, col) => Math.max(...all.map((r) => (r[col] ?? "").length)));
|
|
1836
|
+
return all.map(
|
|
1837
|
+
(r) => r.map((cell2, col) => col === r.length - 1 ? cell2 : cell2.padEnd(widths[col])).join(" ").trimEnd()
|
|
1838
|
+
).map((line) => `${line}
|
|
1839
|
+
`).join("");
|
|
1840
|
+
}
|
|
1841
|
+
var DASH = "\u2013";
|
|
1842
|
+
|
|
1843
|
+
// src/commands/defaults.ts
|
|
1844
|
+
var DEFAULT_OPTIONS = { model: { value: true }, effort: { value: true }, args: { value: true } };
|
|
1845
|
+
function applyDefaultOptions(p, home, into) {
|
|
1846
|
+
const model = stringOption(p, "model");
|
|
1847
|
+
if (model !== void 0) {
|
|
1848
|
+
if (!model.trim()) throw new UsageError("--model needs a model name, e.g. opus");
|
|
1849
|
+
into.model = model;
|
|
1850
|
+
}
|
|
1851
|
+
const effort = stringOption(p, "effort");
|
|
1852
|
+
if (effort !== void 0) {
|
|
1853
|
+
if (!isEffort(effort)) throw new UsageError(`--effort must be one of ${EFFORTS.join(", ")}`);
|
|
1854
|
+
into.effort = effort;
|
|
1855
|
+
}
|
|
1856
|
+
const args = stringOption(p, "args");
|
|
1857
|
+
if (args !== void 0) into.args = splitShellWords(args, home);
|
|
1858
|
+
}
|
|
1859
|
+
function formatDefaults(d) {
|
|
1860
|
+
return [
|
|
1861
|
+
` model ${d.model ?? DASH}`,
|
|
1862
|
+
` effort ${d.effort ?? DASH}`,
|
|
1863
|
+
` args ${d.args.length ? quoteShellWords(d.args) : DASH}`
|
|
1864
|
+
].map((l) => `${l}
|
|
1865
|
+
`).join("");
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/commands/add.ts
|
|
1869
|
+
var USAGE = 'usage: xclaude add <name> [--model M] [--effort E] [--args "\u2026"]';
|
|
1870
|
+
async function addCommand(ctx, config, args) {
|
|
1871
|
+
const p = parseOptions(args, DEFAULT_OPTIONS, "add");
|
|
1872
|
+
const [name, ...extra] = p.positionals;
|
|
1873
|
+
if (!name || extra.length) throw new UsageError(USAGE);
|
|
1874
|
+
const problem2 = accountNameProblem(name);
|
|
1875
|
+
if (problem2) throw new UsageError(problem2);
|
|
1876
|
+
if (Object.hasOwn(config.accounts, name)) throw new XError(`account "${name}" already exists; change its defaults with: xclaude set ${name}`);
|
|
1877
|
+
const defaults = { model: null, effort: null, args: [] };
|
|
1878
|
+
applyDefaultOptions(p, ctx.paths.home, defaults);
|
|
1879
|
+
const claude = findClaude(ctx.env, config, ctx.paths.home, ctx.selfPath);
|
|
1880
|
+
const log = (line) => ctx.io.err(`${line}
|
|
1881
|
+
`);
|
|
1882
|
+
const dir = accountDir(ctx.paths, name);
|
|
1883
|
+
const refusal = unmanageable(dir, ctx.paths.store);
|
|
1884
|
+
if (refusal) throw new XError(`not adding ${name}: ${tildify(dir, ctx.paths.home)} ${refusal}. Replace it with a real folder, or remove it, then add ${name} again.`);
|
|
1885
|
+
if (fs14.existsSync(dir)) log(`xclaude: ${tildify(dir, ctx.paths.home)} already exists; reusing it`);
|
|
1886
|
+
mkdirPrivate(dir);
|
|
1887
|
+
const res = repairAccount({
|
|
1888
|
+
paths: ctx.paths,
|
|
1889
|
+
account: name,
|
|
1890
|
+
table: shareTable(config, ctx.switches),
|
|
1891
|
+
lockWaitMs: Infinity,
|
|
1892
|
+
replaceWrongLinks: false,
|
|
1893
|
+
log
|
|
1894
|
+
});
|
|
1895
|
+
for (const pr of res.problems) log(`xclaude: ${problemLine(name, pr)}`);
|
|
1896
|
+
config = loadConfig(ctx.paths, { create: false }).config;
|
|
1897
|
+
config.accounts[name] = defaults;
|
|
1898
|
+
saveConfig(ctx.paths, config);
|
|
1899
|
+
log(`xclaude: added ${name}. Starting Claude Code in it: go through the first-run screens, log in with /login, then quit.`);
|
|
1900
|
+
const env = identityEnv(ctx, name);
|
|
1901
|
+
await runChild(claude, [], env);
|
|
1902
|
+
const status = await authStatus(claude, env);
|
|
1903
|
+
if (!status.loggedIn) {
|
|
1904
|
+
log(`xclaude: ${name} isn't logged in yet. Log in later with \`xclaude ${name} auth login\`, or /login inside \`xclaude ${name}\`.`);
|
|
1905
|
+
return EXIT_OK;
|
|
1906
|
+
}
|
|
1907
|
+
log(`xclaude: ${name} is logged in as ${describeLogin(status)}. Launch it with: xclaude ${name}`);
|
|
1908
|
+
const others = identities(config).filter((n) => n !== name);
|
|
1909
|
+
const theirs = await Promise.all(others.map((n) => authStatus(claude, identityEnv(ctx, n))));
|
|
1910
|
+
others.forEach((other, i) => {
|
|
1911
|
+
const s = theirs[i];
|
|
1912
|
+
if (s.loggedIn && s.email === status.email && s.orgId === status.orgId) {
|
|
1913
|
+
log(`xclaude: warning: ${name} and ${other} use the same login (${describeLogin(status)})`);
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
return EXIT_OK;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// src/commands/doctor.ts
|
|
1920
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
1921
|
+
import fs19 from "node:fs";
|
|
1922
|
+
import path14 from "node:path";
|
|
1923
|
+
|
|
1924
|
+
// src/link/normalize.ts
|
|
1925
|
+
import fs15 from "node:fs";
|
|
1926
|
+
import path10 from "node:path";
|
|
1927
|
+
var REGISTRY_FILES = ["plugins/installed_plugins.json", "plugins/known_marketplaces.json"];
|
|
1928
|
+
function escapeRe(s) {
|
|
1929
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1930
|
+
}
|
|
1931
|
+
function rewrite(value, re, to, onChange) {
|
|
1932
|
+
if (typeof value === "string") {
|
|
1933
|
+
const next = value.replace(re, (_m, _name, sep) => `${to}${sep}`);
|
|
1934
|
+
if (next !== value) onChange();
|
|
1935
|
+
return next;
|
|
1936
|
+
}
|
|
1937
|
+
if (Array.isArray(value)) return value.map((v) => rewrite(v, re, to, onChange));
|
|
1938
|
+
if (value && typeof value === "object") {
|
|
1939
|
+
const out = {};
|
|
1940
|
+
for (const [k, v] of Object.entries(value)) out[k] = rewrite(v, re, to, onChange);
|
|
1941
|
+
return out;
|
|
1942
|
+
}
|
|
1943
|
+
return value;
|
|
1944
|
+
}
|
|
1945
|
+
function planNormalization(paths, account) {
|
|
1946
|
+
const accountsDir = path10.join(paths.xhome, "accounts");
|
|
1947
|
+
const to = path10.join(paths.home, ".claude");
|
|
1948
|
+
const name = account ? escapeRe(account) : "[a-z][a-z0-9-]{0,31}";
|
|
1949
|
+
const re = new RegExp(`^${escapeRe(accountsDir)}/(${name})(/|$)`);
|
|
1950
|
+
const plans = [];
|
|
1951
|
+
for (const rel of REGISTRY_FILES) {
|
|
1952
|
+
const file = realpathOrNull(path10.join(paths.store, rel));
|
|
1953
|
+
if (!file) continue;
|
|
1954
|
+
const st = lstatOrNull(file);
|
|
1955
|
+
if (!st?.isFile()) continue;
|
|
1956
|
+
const original = fs15.readFileSync(file, "utf8");
|
|
1957
|
+
if (!original.includes(accountsDir)) continue;
|
|
1958
|
+
let parsed;
|
|
1959
|
+
try {
|
|
1960
|
+
parsed = JSON.parse(original);
|
|
1961
|
+
} catch {
|
|
1962
|
+
continue;
|
|
1963
|
+
}
|
|
1964
|
+
let count = 0;
|
|
1965
|
+
const next = rewrite(parsed, re, to, () => count++);
|
|
1966
|
+
if (!count) continue;
|
|
1967
|
+
const text = `${JSON.stringify(next, null, 2)}${original.endsWith("\n") ? "\n" : ""}`;
|
|
1968
|
+
plans.push({ file, text, mtimeMs: st.mtimeMs, size: st.size, ino: st.ino, count });
|
|
1969
|
+
}
|
|
1970
|
+
return plans;
|
|
1971
|
+
}
|
|
1972
|
+
function applyNormalization(plans) {
|
|
1973
|
+
const changed = [];
|
|
1974
|
+
const skipped = [];
|
|
1975
|
+
for (const plan of plans) {
|
|
1976
|
+
const st = lstatOrNull(plan.file);
|
|
1977
|
+
if (!st || st.mtimeMs !== plan.mtimeMs || st.size !== plan.size || st.ino !== plan.ino) {
|
|
1978
|
+
skipped.push(plan.file);
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
writeFileAtomic(plan.file, plan.text);
|
|
1982
|
+
changed.push(plan.file);
|
|
1983
|
+
}
|
|
1984
|
+
return { changed, skipped };
|
|
1985
|
+
}
|
|
1986
|
+
function normalizePaths(paths, opts) {
|
|
1987
|
+
const plans = planNormalization(paths, opts.account);
|
|
1988
|
+
if (!plans.length) return { changed: [], skipped: [], busy: false };
|
|
1989
|
+
const lock = DirLock.acquire(path10.join(paths.locks, "store"), STORE_LOCK, { waitMs: opts.lockWaitMs });
|
|
1990
|
+
if (!lock) return { changed: [], skipped: plans.map((p) => p.file), busy: true };
|
|
1991
|
+
try {
|
|
1992
|
+
const result = applyNormalization(plans);
|
|
1993
|
+
for (const plan of plans.filter((p) => result.changed.includes(p.file))) {
|
|
1994
|
+
opts.log(`xclaude: rewrote ${plan.count} account-dir path${plan.count > 1 ? "s" : ""} in ${tildify(plan.file, paths.home)} to ~/.claude/\u2026`);
|
|
1995
|
+
}
|
|
1996
|
+
return { ...result, busy: false };
|
|
1997
|
+
} finally {
|
|
1998
|
+
lock.release();
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// src/link/leftover.ts
|
|
2003
|
+
import fs16 from "node:fs";
|
|
2004
|
+
import path11 from "node:path";
|
|
2005
|
+
function strayFolders(paths, config) {
|
|
2006
|
+
let names;
|
|
2007
|
+
try {
|
|
2008
|
+
names = fs16.readdirSync(paths.accounts);
|
|
2009
|
+
} catch {
|
|
2010
|
+
return [];
|
|
2011
|
+
}
|
|
2012
|
+
const accounts = Object.keys(config.accounts).map((n) => lstatOrNull(accountDir(paths, n))).filter((st) => st !== null);
|
|
2013
|
+
const out = [];
|
|
2014
|
+
for (const name of names.sort()) {
|
|
2015
|
+
if (name.endsWith(".lock") || name === ".DS_Store" || Object.hasOwn(config.accounts, name)) continue;
|
|
2016
|
+
const dir = path11.join(paths.accounts, name);
|
|
2017
|
+
const st = lstatOrNull(dir);
|
|
2018
|
+
if (!st) continue;
|
|
2019
|
+
if (st.isSymbolicLink()) out.push({ name, dir, kind: "link", entries: [] });
|
|
2020
|
+
else if (!st.isDirectory() || accounts.some((a) => a.dev === st.dev && a.ino === st.ino)) continue;
|
|
2021
|
+
else if (accountNameProblem(name)) out.push({ name, dir, kind: "stray", entries: [] });
|
|
2022
|
+
else {
|
|
2023
|
+
const entries = fs16.readdirSync(dir).filter((e) => e !== ".DS_Store" && !lstatOrNull(path11.join(dir, e))?.isSymbolicLink());
|
|
2024
|
+
out.push({ name, dir, kind: entries.length ? "content" : "leftover", entries });
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return out;
|
|
2028
|
+
}
|
|
2029
|
+
function leftoverNames(paths, config) {
|
|
2030
|
+
return strayFolders(paths, config).filter((f) => f.kind === "leftover").map((f) => f.name);
|
|
2031
|
+
}
|
|
2032
|
+
function linkNames(table) {
|
|
2033
|
+
return /* @__PURE__ */ new Set([...SHARED_DIRS, ...table.dirs, HISTORY]);
|
|
2034
|
+
}
|
|
2035
|
+
function hasContent(p) {
|
|
2036
|
+
const st = lstatOrNull(p);
|
|
2037
|
+
if (!st) return false;
|
|
2038
|
+
if (st.isDirectory()) return fs16.readdirSync(p).some((e) => e !== ".DS_Store");
|
|
2039
|
+
return st.size > 0;
|
|
2040
|
+
}
|
|
2041
|
+
function keepOnlyLinks(dir, table) {
|
|
2042
|
+
if (lstatOrNull(dir)?.isSymbolicLink()) throw new Error(`${dir} is a symlink`);
|
|
2043
|
+
const shared = linkNames(table);
|
|
2044
|
+
const content = [];
|
|
2045
|
+
let links = 0;
|
|
2046
|
+
for (const name of fs16.readdirSync(dir)) {
|
|
2047
|
+
const p = path11.join(dir, name);
|
|
2048
|
+
if (shared.has(name) && lstatOrNull(p)?.isSymbolicLink()) links++;
|
|
2049
|
+
else if (shared.has(name) && hasContent(p)) content.push(name);
|
|
2050
|
+
else removeTree(p);
|
|
2051
|
+
}
|
|
2052
|
+
if (links || content.length) return { kept: true, content };
|
|
2053
|
+
fs16.rmdirSync(dir);
|
|
2054
|
+
return { kept: false, content };
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// src/link/unknown.ts
|
|
2058
|
+
import fs17 from "node:fs";
|
|
2059
|
+
function unknownEntries(names, table) {
|
|
2060
|
+
return names.filter((n) => !isIgnored(n) && !isKnown(n, table)).sort();
|
|
2061
|
+
}
|
|
2062
|
+
function takeNewUnknown(state, account, unknown) {
|
|
2063
|
+
const seen = new Set(state.seenUnknownEntries[account] ?? []);
|
|
2064
|
+
const fresh = unknown.filter((n) => !seen.has(n));
|
|
2065
|
+
if (fresh.length) state.seenUnknownEntries[account] = [...seen, ...fresh].sort();
|
|
2066
|
+
return fresh;
|
|
2067
|
+
}
|
|
2068
|
+
function unknownNotice(account, entry, configLabel) {
|
|
2069
|
+
return `xclaude: new Claude Code entry "${entry}" in ${account} stays per-account; to share it, add "${entry}" to share.add in ${configLabel}`;
|
|
2070
|
+
}
|
|
2071
|
+
function storeUnknownEntries(store, table) {
|
|
2072
|
+
let names;
|
|
2073
|
+
try {
|
|
2074
|
+
names = fs17.readdirSync(store);
|
|
2075
|
+
} catch (e) {
|
|
2076
|
+
if (errCode(e) === "ENOENT") return [];
|
|
2077
|
+
throw e;
|
|
2078
|
+
}
|
|
2079
|
+
return unknownEntries(names, table);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// src/shell/rc.ts
|
|
2083
|
+
import fs18 from "node:fs";
|
|
2084
|
+
import path12 from "node:path";
|
|
2085
|
+
var START = "# >>> xclaude >>>";
|
|
2086
|
+
var END = "# <<< xclaude <<<";
|
|
2087
|
+
function dquote(s) {
|
|
2088
|
+
return `"${s.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
2089
|
+
}
|
|
2090
|
+
function blockFor(initFile2) {
|
|
2091
|
+
const q = dquote(initFile2);
|
|
2092
|
+
return `${START}
|
|
2093
|
+
# Managed by \`xclaude shell install\`. Remove with \`xclaude shell uninstall\`.
|
|
2094
|
+
[ -f ${q} ] && . ${q}
|
|
2095
|
+
${END}
|
|
2096
|
+
`;
|
|
2097
|
+
}
|
|
2098
|
+
function findBlock(lines, file) {
|
|
2099
|
+
const start = lines.findIndex((l) => l.trim() === START);
|
|
2100
|
+
if (start < 0) return null;
|
|
2101
|
+
const end = lines.findIndex((l, i) => i > start && l.trim() === END);
|
|
2102
|
+
if (end < 0) {
|
|
2103
|
+
throw new XError(`${file} has "${START}" without "${END}"; fix or remove that block by hand, then run this again`);
|
|
2104
|
+
}
|
|
2105
|
+
return [start, end];
|
|
2106
|
+
}
|
|
2107
|
+
function upsertBlock(text, block, file) {
|
|
2108
|
+
const lines = text.split("\n");
|
|
2109
|
+
const range = findBlock(lines, file);
|
|
2110
|
+
const blockLines = block.replace(/\n$/, "").split("\n");
|
|
2111
|
+
if (range) {
|
|
2112
|
+
lines.splice(range[0], range[1] - range[0] + 1, ...blockLines);
|
|
2113
|
+
return lines.join("\n");
|
|
2114
|
+
}
|
|
2115
|
+
if (text === "") return block;
|
|
2116
|
+
return `${text}${text.endsWith("\n") ? "" : "\n"}
|
|
2117
|
+
${block}`;
|
|
2118
|
+
}
|
|
2119
|
+
function removeBlock(text, file) {
|
|
2120
|
+
const lines = text.split("\n");
|
|
2121
|
+
const range = findBlock(lines, file);
|
|
2122
|
+
if (!range) return { text, found: false };
|
|
2123
|
+
let [start] = range;
|
|
2124
|
+
const end = range[1];
|
|
2125
|
+
if (start > 0 && lines[start - 1] === "" && (lines[end + 1] === void 0 || lines[end + 1] === "")) start--;
|
|
2126
|
+
lines.splice(start, end - start + 1);
|
|
2127
|
+
return { text: lines.join("\n"), found: true };
|
|
2128
|
+
}
|
|
2129
|
+
function sourcesBashrc(text) {
|
|
2130
|
+
return text.split("\n").some((l) => !/^\s*#/.test(l) && /\.bashrc\b/.test(l) && /(^|[\s;&|({])(\.|source)\s/.test(l));
|
|
2131
|
+
}
|
|
2132
|
+
function rcTargets(env, home, platform, want) {
|
|
2133
|
+
const zshrc = path12.join(env.ZDOTDIR || home, ".zshrc");
|
|
2134
|
+
const bashrc = path12.join(home, ".bashrc");
|
|
2135
|
+
const explicit = want.bash || want.zsh;
|
|
2136
|
+
const targets = [];
|
|
2137
|
+
if (explicit ? want.zsh : fs18.existsSync(zshrc)) targets.push({ shell: "zsh", file: zshrc });
|
|
2138
|
+
if (explicit ? want.bash : fs18.existsSync(bashrc)) {
|
|
2139
|
+
targets.push({ shell: "bash", file: bashrc });
|
|
2140
|
+
const profile = path12.join(home, ".bash_profile");
|
|
2141
|
+
const text = platform === "darwin" ? readFileOrNull(profile) : null;
|
|
2142
|
+
if (text !== null && !sourcesBashrc(text)) targets.push({ shell: "bash", file: profile });
|
|
2143
|
+
}
|
|
2144
|
+
return targets;
|
|
2145
|
+
}
|
|
2146
|
+
function writeRc(file, text, previous) {
|
|
2147
|
+
if (text === previous) return { file, status: "unchanged" };
|
|
2148
|
+
const real = realpathOrNull(file);
|
|
2149
|
+
if (real === null && fs18.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) return { file, status: "manual", text };
|
|
2150
|
+
try {
|
|
2151
|
+
const target = real ?? file;
|
|
2152
|
+
fs18.accessSync(path12.dirname(target), fs18.constants.W_OK);
|
|
2153
|
+
if (previous !== null) fs18.accessSync(target, fs18.constants.W_OK);
|
|
2154
|
+
writeFileAtomic(target, text, { mode: 420 });
|
|
2155
|
+
return { file, status: "written" };
|
|
2156
|
+
} catch (e) {
|
|
2157
|
+
if (errCode(e) === "EACCES" || errCode(e) === "EPERM" || errCode(e) === "EROFS") return { file, status: "manual", text };
|
|
2158
|
+
throw e;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
function knownRcFiles(env, home) {
|
|
2162
|
+
return [
|
|
2163
|
+
{ shell: "zsh", file: path12.join(env.ZDOTDIR || home, ".zshrc") },
|
|
2164
|
+
{ shell: "bash", file: path12.join(home, ".bashrc") },
|
|
2165
|
+
{ shell: "bash", file: path12.join(home, ".bash_profile") }
|
|
2166
|
+
];
|
|
2167
|
+
}
|
|
2168
|
+
function installedIn(env, home) {
|
|
2169
|
+
return knownRcFiles(env, home).filter((t) => (readFileOrNull(t.file) ?? "").split("\n").some((l) => l.trim() === START)).map((t) => t.file);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// src/tmux/client.ts
|
|
2173
|
+
import { spawnSync } from "node:child_process";
|
|
2174
|
+
import path13 from "node:path";
|
|
2175
|
+
var STRIPPED_ENV = [
|
|
2176
|
+
"CLAUDE_CONFIG_DIR",
|
|
2177
|
+
"XCLAUDE_ACCOUNT",
|
|
2178
|
+
"CLAUDE_CODE_CHILD_SESSION",
|
|
2179
|
+
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
|
|
2180
|
+
"CLAUDECODE",
|
|
2181
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
2182
|
+
"CLAUDE_CODE_SESSION_ATTENDED",
|
|
2183
|
+
"CLAUDE_PID",
|
|
2184
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
2185
|
+
"CLAUDE_CODE_EXECPATH",
|
|
2186
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
2187
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
2188
|
+
"CLAUDE_CODE_BRIDGE_SESSION_ID",
|
|
2189
|
+
"CLAUDE_CODE_INVOKED_SKILLS",
|
|
2190
|
+
"CLAUDE_CODE_TMPDIR",
|
|
2191
|
+
"CLAUDE_EFFORT",
|
|
2192
|
+
"AI_AGENT"
|
|
2193
|
+
];
|
|
2194
|
+
function tmuxEnv(env) {
|
|
2195
|
+
const out = {};
|
|
2196
|
+
for (const [k, v] of Object.entries(env)) if (v !== void 0 && !STRIPPED_ENV.includes(k)) out[k] = v;
|
|
2197
|
+
return out;
|
|
2198
|
+
}
|
|
2199
|
+
function escapeArg(arg) {
|
|
2200
|
+
return arg.endsWith(";") ? `${arg.slice(0, -1)}\\;` : arg;
|
|
2201
|
+
}
|
|
2202
|
+
var TMUX_INSTALL_HINT = process.platform === "darwin" ? "install it with: brew install tmux" : "install it with your package manager, e.g. sudo apt install tmux";
|
|
2203
|
+
function findTmux(env) {
|
|
2204
|
+
for (const dir of (env.PATH ?? "").split(path13.delimiter)) {
|
|
2205
|
+
if (!dir || !path13.isAbsolute(dir)) continue;
|
|
2206
|
+
const candidate = path13.join(dir, "tmux");
|
|
2207
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
2208
|
+
}
|
|
2209
|
+
return null;
|
|
2210
|
+
}
|
|
2211
|
+
var Tmux = class {
|
|
2212
|
+
bin;
|
|
2213
|
+
env;
|
|
2214
|
+
constructor(env) {
|
|
2215
|
+
const bin = findTmux(env);
|
|
2216
|
+
if (!bin) throw new XError(`tmux isn't installed (xclaude tmux needs tmux 3.0 or later); ${TMUX_INSTALL_HINT}`);
|
|
2217
|
+
this.bin = bin;
|
|
2218
|
+
this.env = tmuxEnv(env);
|
|
2219
|
+
}
|
|
2220
|
+
run(args) {
|
|
2221
|
+
const res = spawnSync(this.bin, args.map(escapeArg), { env: this.env, encoding: "utf8", timeout: 15e3 });
|
|
2222
|
+
if (res.error) throw new XError(`running tmux failed: ${res.error.message}`);
|
|
2223
|
+
return { code: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
2224
|
+
}
|
|
2225
|
+
/** Runs a command that must succeed. */
|
|
2226
|
+
must(args) {
|
|
2227
|
+
const res = this.run(args);
|
|
2228
|
+
if (res.code !== 0) throw new XError(`tmux ${args[0]} failed: ${res.stderr.trim() || `exit code ${res.code}`}`);
|
|
2229
|
+
return res.stdout;
|
|
2230
|
+
}
|
|
2231
|
+
/** [major, minor], or null when `tmux -V` can't be read. */
|
|
2232
|
+
version() {
|
|
2233
|
+
const m = /(\d+)\.(\d+)/.exec(this.run(["-V"]).stdout);
|
|
2234
|
+
return m ? [Number(m[1]), Number(m[2])] : null;
|
|
2235
|
+
}
|
|
2236
|
+
requireVersion() {
|
|
2237
|
+
const v = this.version();
|
|
2238
|
+
if (v && (v[0] < 3 || v[0] === 3 && v[1] < 0)) throw new XError(`tmux ${v.join(".")} is too old; xclaude tmux needs 3.0 or later`);
|
|
2239
|
+
}
|
|
2240
|
+
/** Whether a session named exactly `name` exists (the = prefix turns off tmux's prefix matching). */
|
|
2241
|
+
hasSession(name) {
|
|
2242
|
+
return this.run(["has-session", "-t", `=${name}`]).code === 0;
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
function isNoServer(stderr) {
|
|
2246
|
+
return /no server running|\(No such file or directory\)|server exited unexpectedly/i.test(stderr);
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// src/commands/doctor.ts
|
|
2250
|
+
var MARK = { ok: "\u2713", info: "\xB7", warn: "!", error: "\u2717" };
|
|
2251
|
+
var Report = class {
|
|
2252
|
+
lines = [];
|
|
2253
|
+
errors = 0;
|
|
2254
|
+
warnings = 0;
|
|
2255
|
+
section(title) {
|
|
2256
|
+
this.lines.push(this.lines.length ? `
|
|
2257
|
+
${title}` : title);
|
|
2258
|
+
}
|
|
2259
|
+
add(level, text, indent = 1) {
|
|
2260
|
+
if (level === "error") this.errors++;
|
|
2261
|
+
if (level === "warn") this.warnings++;
|
|
2262
|
+
this.lines.push(`${" ".repeat(indent)}${MARK[level]} ${text}`);
|
|
2263
|
+
}
|
|
2264
|
+
};
|
|
2265
|
+
function managedDirs(ctx) {
|
|
2266
|
+
if (ctx.env.XCLAUDE_MANAGED_SETTINGS_DIR) return { dirs: [ctx.env.XCLAUDE_MANAGED_SETTINGS_DIR], macDefaults: false };
|
|
2267
|
+
if (process.platform === "darwin") return { dirs: ["/Library/Application Support/ClaudeCode"], macDefaults: true };
|
|
2268
|
+
return { dirs: ["/etc/claude-code"], macDefaults: false };
|
|
2269
|
+
}
|
|
2270
|
+
function readForcedOrgs(ctx) {
|
|
2271
|
+
const { dirs, macDefaults } = managedDirs(ctx);
|
|
2272
|
+
if (macDefaults) {
|
|
2273
|
+
const res = spawnSync2("defaults", ["read", "com.anthropic.claudecode", "forceLoginOrgUUID"], { encoding: "utf8", timeout: 5e3 });
|
|
2274
|
+
const orgs2 = res.status === 0 ? [...(res.stdout ?? "").matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)].map((m) => m[0]) : [];
|
|
2275
|
+
if (orgs2.length) return { source: "the com.anthropic.claudecode preferences", orgs: orgs2 };
|
|
2276
|
+
}
|
|
2277
|
+
const files = [];
|
|
2278
|
+
for (const dir of dirs) {
|
|
2279
|
+
files.push(path14.join(dir, "managed-settings.json"));
|
|
2280
|
+
try {
|
|
2281
|
+
for (const name of fs19.readdirSync(path14.join(dir, "managed-settings.d")).sort()) {
|
|
2282
|
+
if (name.endsWith(".json")) files.push(path14.join(dir, "managed-settings.d", name));
|
|
2283
|
+
}
|
|
2284
|
+
} catch {
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
let orgs = null;
|
|
2288
|
+
let isList = false;
|
|
2289
|
+
let sources = [];
|
|
2290
|
+
for (const file of files) {
|
|
2291
|
+
const text = readFileOrNull(file);
|
|
2292
|
+
if (text === null) continue;
|
|
2293
|
+
let value;
|
|
2294
|
+
try {
|
|
2295
|
+
value = JSON.parse(text).forceLoginOrgUUID;
|
|
2296
|
+
} catch {
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
if (typeof value === "string") {
|
|
2300
|
+
orgs = [value];
|
|
2301
|
+
isList = false;
|
|
2302
|
+
sources = [file];
|
|
2303
|
+
} else if (Array.isArray(value)) {
|
|
2304
|
+
const list = value.filter((v) => typeof v === "string");
|
|
2305
|
+
if (isList && orgs) {
|
|
2306
|
+
orgs = [...orgs, ...list];
|
|
2307
|
+
sources.push(file);
|
|
2308
|
+
} else {
|
|
2309
|
+
orgs = list;
|
|
2310
|
+
sources = [file];
|
|
2311
|
+
}
|
|
2312
|
+
isList = true;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
return orgs && orgs.length ? { source: sources.join(" and "), orgs } : null;
|
|
2316
|
+
}
|
|
2317
|
+
function remoteCleanupDays(dir) {
|
|
2318
|
+
try {
|
|
2319
|
+
const json = JSON.parse(readFileOrNull(path14.join(dir, "remote-settings.json")) ?? "null");
|
|
2320
|
+
const value = json?.cleanupPeriodDays ?? json?.settings?.cleanupPeriodDays;
|
|
2321
|
+
return typeof value === "number" ? value : null;
|
|
2322
|
+
} catch {
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
function versionOf(bin, args) {
|
|
2327
|
+
const res = spawnSync2(bin, args, { encoding: "utf8", timeout: 15e3 });
|
|
2328
|
+
return res.status === 0 ? (res.stdout ?? "").trim().split("\n")[0] || null : null;
|
|
2329
|
+
}
|
|
2330
|
+
async function doctorCommand(ctx, args) {
|
|
2331
|
+
const p = parseOptions(args, { fix: {} }, "doctor");
|
|
2332
|
+
if (p.positionals.length) throw new UsageError("usage: xclaude doctor [--fix]");
|
|
2333
|
+
const home = ctx.paths.home;
|
|
2334
|
+
const t = (p2) => tildify(p2, home);
|
|
2335
|
+
const report = new Report();
|
|
2336
|
+
let config = defaultConfig();
|
|
2337
|
+
let configProblem = null;
|
|
2338
|
+
try {
|
|
2339
|
+
config = loadConfig(ctx.paths, { create: false }).config;
|
|
2340
|
+
} catch (e) {
|
|
2341
|
+
if (!(e instanceof XError)) throw e;
|
|
2342
|
+
configProblem = e.message;
|
|
2343
|
+
}
|
|
2344
|
+
const table = shareTable(config, ctx.switches);
|
|
2345
|
+
if (p.values.fix && !configProblem) {
|
|
2346
|
+
const log = (line) => ctx.io.err(`${line}
|
|
2347
|
+
`);
|
|
2348
|
+
for (const name of Object.keys(config.accounts)) {
|
|
2349
|
+
repairAccount({ paths: ctx.paths, account: name, table, lockWaitMs: Infinity, replaceWrongLinks: true, log });
|
|
2350
|
+
}
|
|
2351
|
+
if (ctx.switches.normalizePaths && table.dirs.includes("plugins")) normalizePaths(ctx.paths, { lockWaitMs: Infinity, log });
|
|
2352
|
+
}
|
|
2353
|
+
report.section("Tools");
|
|
2354
|
+
let claude = null;
|
|
2355
|
+
try {
|
|
2356
|
+
claude = findClaude(ctx.env, config, home, ctx.selfPath);
|
|
2357
|
+
report.add("ok", `claude ${versionOf(claude, ["--version"]) ?? "(version unknown)"} at ${t(claude)}`);
|
|
2358
|
+
} catch (e) {
|
|
2359
|
+
if (!(e instanceof XError)) throw e;
|
|
2360
|
+
report.add("error", e.message.includes("isn't installed") ? `Claude Code isn't installed; ${INSTALL_HINT}` : e.message);
|
|
2361
|
+
}
|
|
2362
|
+
const tmuxBin = findTmux(ctx.env);
|
|
2363
|
+
if (!tmuxBin) report.add("warn", `tmux isn't installed, so xclaude tmux won't work (it needs 3.0 or later); ${TMUX_INSTALL_HINT}`);
|
|
2364
|
+
else {
|
|
2365
|
+
const v = new Tmux(ctx.env).version();
|
|
2366
|
+
if (v && v[0] < 3) report.add("warn", `tmux ${v.join(".")} is too old for xclaude tmux (it needs 3.0 or later)`);
|
|
2367
|
+
else report.add("ok", `tmux ${v ? v.join(".") : "(version unknown)"}`);
|
|
2368
|
+
}
|
|
2369
|
+
report.add("ok", `Node ${process.versions.node}`);
|
|
2370
|
+
report.section("Config");
|
|
2371
|
+
if (configProblem) report.add("error", configProblem);
|
|
2372
|
+
else report.add("ok", `${t(ctx.paths.config)}${fs19.existsSync(ctx.paths.config) ? "" : " (not created yet)"}`);
|
|
2373
|
+
for (const name of table.refused) report.add("warn", `share.add "${name}" is ignored: never-shared entries and file names can't be shared`);
|
|
2374
|
+
const names = identities(config);
|
|
2375
|
+
const logins = /* @__PURE__ */ new Map();
|
|
2376
|
+
if (claude) {
|
|
2377
|
+
const statuses = await Promise.all(names.map((n) => authStatus(claude, identityEnv(ctx, n))));
|
|
2378
|
+
names.forEach((n, i) => logins.set(n, statuses[i]));
|
|
2379
|
+
}
|
|
2380
|
+
const forced = readForcedOrgs(ctx);
|
|
2381
|
+
report.section("Accounts");
|
|
2382
|
+
if (configProblem) report.add("info", "not checked: fix the config first (account dirs are left alone)");
|
|
2383
|
+
else if (!names.length) report.add("info", "no accounts yet; add one with: xclaude add <name>");
|
|
2384
|
+
const storeDev = statOrNull(ctx.paths.store)?.dev;
|
|
2385
|
+
for (const name of names) {
|
|
2386
|
+
const login = logins.get(name);
|
|
2387
|
+
report.lines.push(` ${name}${login ? ` ${describeLogin(login)}` : ""}`);
|
|
2388
|
+
if (name === MAIN) {
|
|
2389
|
+
report.add("info", "the main identity: the login in ~/.claude, never linked", 2);
|
|
2390
|
+
} else {
|
|
2391
|
+
const dir = accountDir(ctx.paths, name);
|
|
2392
|
+
const insp = lstatOrNull(dir)?.isSymbolicLink() ? null : inspectAccount(ctx.paths, name, table, true);
|
|
2393
|
+
if (!insp) {
|
|
2394
|
+
report.add("error", `${t(dir)} is a symlink; xclaude only manages real folders there, so it leaves this one alone`, 2);
|
|
2395
|
+
} else if (!insp.exists) {
|
|
2396
|
+
report.add("error", `${t(dir)} is missing; the next launch (or --fix) recreates it, then log in again`, 2);
|
|
2397
|
+
} else {
|
|
2398
|
+
const problems = describeProblems(insp, ctx.paths);
|
|
2399
|
+
if (problems.length) {
|
|
2400
|
+
for (const pr of problems) report.add("error", `${pr.entry} ${pr.message}`, 2);
|
|
2401
|
+
} else report.add("ok", `links into ${t(ctx.paths.store)}`, 2);
|
|
2402
|
+
if (insp.stub === "differs") {
|
|
2403
|
+
report.add("warn", "CLAUDE.md isn't the import stub, so it's left alone; move what it says into ~/.claude/CLAUDE.md to share it", 2);
|
|
2404
|
+
} else if (insp.stub === "create" || insp.stub === "remove") {
|
|
2405
|
+
report.add("error", `the CLAUDE.md stub needs ${insp.stub === "create" ? "creating" : "removing"}; run xclaude doctor --fix`, 2);
|
|
2406
|
+
}
|
|
2407
|
+
const unknown = unknownEntries(insp.names, table);
|
|
2408
|
+
if (unknown.length) report.add("info", `per-account entries xclaude doesn't know: ${unknown.join(", ")}`, 2);
|
|
2409
|
+
const dev = statOrNull(dir)?.dev;
|
|
2410
|
+
if (storeDev !== void 0 && dev !== void 0 && dev !== storeDev) {
|
|
2411
|
+
report.add(
|
|
2412
|
+
"warn",
|
|
2413
|
+
`${t(ctx.paths.store)} and ${t(dir)} are on different filesystems, so left-behind directories can't be merged (hard links don't cross filesystems); keep ~/.claude and ~/.xclaude on one filesystem`,
|
|
2414
|
+
2
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
const days = remoteCleanupDays(dir);
|
|
2418
|
+
if (days !== null && days < 30) {
|
|
2419
|
+
report.add("warn", `its organization sets cleanupPeriodDays to ${days}: its sessions delete everyone's shared transcripts older than ${days} days`, 2);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
if (login) {
|
|
2424
|
+
if (login.error) report.add("warn", `login status unknown: ${login.error}`, 2);
|
|
2425
|
+
else if (!login.loggedIn) report.add("warn", `not logged in; run: xclaude ${name} auth login`, 2);
|
|
2426
|
+
if (forced && login.loggedIn && login.orgId && !forced.orgs.includes(login.orgId)) {
|
|
2427
|
+
report.add("error", `forceLoginOrgUUID (from ${t(forced.source)}) excludes this login's organization (${login.orgName ?? login.orgId}), so it will be refused`, 2);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
const byLogin = /* @__PURE__ */ new Map();
|
|
2432
|
+
for (const [n, s] of logins) {
|
|
2433
|
+
if (s.loggedIn && s.email) byLogin.set(`${s.email}
|
|
2434
|
+
${s.orgId}`, [...byLogin.get(`${s.email}
|
|
2435
|
+
${s.orgId}`) ?? [], n]);
|
|
2436
|
+
}
|
|
2437
|
+
for (const [key, same] of byLogin) {
|
|
2438
|
+
if (same.length > 1) report.add("warn", `${same.join(" and ")} use the same login (${key.split("\n")[0]})`);
|
|
2439
|
+
}
|
|
2440
|
+
for (const f of configProblem ? [] : strayFolders(ctx.paths, config)) {
|
|
2441
|
+
const where = t(f.dir);
|
|
2442
|
+
if (f.kind === "leftover") {
|
|
2443
|
+
report.add("info", `${where}: links kept from the removed account ${f.name}, for its old conversations; delete with: xclaude rm ${f.name}`);
|
|
2444
|
+
} else if (f.kind === "content") {
|
|
2445
|
+
report.add("warn", `${where} isn't an account but holds more than links (${f.entries.join(", ")}): \`xclaude add ${f.name}\` takes it back, or check it and delete it by hand`);
|
|
2446
|
+
} else if (f.kind === "stray") {
|
|
2447
|
+
report.add("info", `${where} isn't an account folder (that name couldn't be one); check it by hand`);
|
|
2448
|
+
} else {
|
|
2449
|
+
report.add("warn", `${where} is a symlink; xclaude only manages real folders there`);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
report.section("Store");
|
|
2453
|
+
const storeUnknown = storeUnknownEntries(ctx.paths.store, table);
|
|
2454
|
+
if (!fs19.existsSync(ctx.paths.store)) report.add("info", `${t(ctx.paths.store)} doesn't exist yet`);
|
|
2455
|
+
else if (storeUnknown.length) report.add("info", `entries in ${t(ctx.paths.store)} that no account links to: ${storeUnknown.join(", ")}`);
|
|
2456
|
+
else report.add("ok", t(ctx.paths.store));
|
|
2457
|
+
const mainDays = remoteCleanupDays(ctx.paths.store);
|
|
2458
|
+
if (mainDays !== null && mainDays < 30) {
|
|
2459
|
+
report.add("warn", `the ~/.claude login's organization sets cleanupPeriodDays to ${mainDays}: plain claude deletes everyone's shared transcripts older than ${mainDays} days`);
|
|
2460
|
+
}
|
|
2461
|
+
for (const entry of SHARED_DIRS) {
|
|
2462
|
+
const p2 = path14.join(ctx.paths.store, entry);
|
|
2463
|
+
if (!fs19.existsSync(p2)) continue;
|
|
2464
|
+
try {
|
|
2465
|
+
fs19.accessSync(p2, fs19.constants.W_OK);
|
|
2466
|
+
} catch {
|
|
2467
|
+
report.add("warn", `${t(p2)} isn't writable, so sessions can't save ${entry} there`);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
report.section("Shell");
|
|
2471
|
+
const rcFiles = installedIn(ctx.env, home);
|
|
2472
|
+
if (rcFiles.length) report.add("ok", `completion loaded from ${rcFiles.map(t).join(", ")}`);
|
|
2473
|
+
else report.add("info", "completion isn't installed (xclaude shell install)");
|
|
2474
|
+
report.add("info", `guard: ${config.guard ? "on" : "off"}`);
|
|
2475
|
+
report.section("Environment");
|
|
2476
|
+
let envFindings = 0;
|
|
2477
|
+
if (ctx.env.CLAUDE_CONFIG_DIR) {
|
|
2478
|
+
report.add("warn", `CLAUDE_CONFIG_DIR is set (${ctx.env.CLAUDE_CONFIG_DIR}), so plain \`claude\` doesn't use ~/.claude`);
|
|
2479
|
+
envFindings++;
|
|
2480
|
+
}
|
|
2481
|
+
if (ctx.env.CLAUDE_CODE_EFFORT_LEVEL) {
|
|
2482
|
+
report.add("warn", `CLAUDE_CODE_EFFORT_LEVEL is set (${ctx.env.CLAUDE_CODE_EFFORT_LEVEL}); it overrides the effort level saved in every account's settings`);
|
|
2483
|
+
envFindings++;
|
|
2484
|
+
}
|
|
2485
|
+
if (!envFindings) report.add("ok", "nothing that overrides accounts");
|
|
2486
|
+
report.lines.push("");
|
|
2487
|
+
const summary = report.errors || report.warnings ? `${report.errors} problem${report.errors === 1 ? "" : "s"}, ${report.warnings} warning${report.warnings === 1 ? "" : "s"}${report.errors && !p.values.fix ? ". Run `xclaude doctor --fix` to repair what can be repaired." : "."}` : "All good.";
|
|
2488
|
+
report.lines.push(summary);
|
|
2489
|
+
ctx.io.out(`${report.lines.join("\n")}
|
|
2490
|
+
`);
|
|
2491
|
+
return report.errors ? EXIT_ERROR : EXIT_OK;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// src/shell/init.ts
|
|
2495
|
+
import fs20 from "node:fs";
|
|
2496
|
+
import path15 from "node:path";
|
|
2497
|
+
function header(shell, version) {
|
|
2498
|
+
return `# xclaude ${version}: ${shell} integration. Written by \`xclaude shell install\`, \`xclaude guard on|off\`
|
|
2499
|
+
# and after xclaude updates; don't edit, changes are overwritten.
|
|
2500
|
+
`;
|
|
2501
|
+
}
|
|
2502
|
+
var GUARD = `unalias claude 2>/dev/null
|
|
2503
|
+
claude() {
|
|
2504
|
+
if [ -n "$XCLAUDE_ACCOUNT" ] || [ -n "$CLAUDE_CODE_CHILD_SESSION" ] || ! command -v xclaude >/dev/null 2>&1; then
|
|
2505
|
+
command claude "$@"
|
|
2506
|
+
else
|
|
2507
|
+
echo "claude is disabled \u2014 use: xclaude <account> (accounts: $(xclaude __names 2>/dev/null))" >&2
|
|
2508
|
+
return 1
|
|
2509
|
+
fi
|
|
2510
|
+
}
|
|
2511
|
+
`;
|
|
2512
|
+
function initBash(version, guard) {
|
|
2513
|
+
return `${header("bash", version)}command -v xclaude >/dev/null 2>&1 || return 0
|
|
2514
|
+
|
|
2515
|
+
_xclaude_complete() {
|
|
2516
|
+
local cur out directive rest line q p
|
|
2517
|
+
cur=\${COMP_WORDS[COMP_CWORD]}
|
|
2518
|
+
[ "$cur" = "=" ] && cur=
|
|
2519
|
+
# bash 3.2 keeps --flag=value in one word; readline completes the part after =.
|
|
2520
|
+
case $cur in -*=*) cur=\${cur#*=} ;; esac
|
|
2521
|
+
out=$(xclaude __complete bash "$COMP_CWORD" "\${COMP_WORDS[@]}" 2>/dev/null) || return 0
|
|
2522
|
+
directive=\${out%%$'\\n'*}
|
|
2523
|
+
case $out in
|
|
2524
|
+
*$'\\n'*) rest=\${out#*$'\\n'} ;;
|
|
2525
|
+
*) rest= ;;
|
|
2526
|
+
esac
|
|
2527
|
+
COMPREPLY=()
|
|
2528
|
+
case $directive in
|
|
2529
|
+
files|dirs)
|
|
2530
|
+
if [ "$directive" = dirs ]; then rest=$(compgen -d -- "$cur"); else rest=$(compgen -f -- "$cur"); fi
|
|
2531
|
+
while IFS= read -r line; do
|
|
2532
|
+
[ -n "$line" ] || continue
|
|
2533
|
+
printf -v q '%q' "$line"
|
|
2534
|
+
case $q in '\\~'*) q=\${q#\\\\} ;; esac
|
|
2535
|
+
p=$line
|
|
2536
|
+
case $p in '~/'*) p=$HOME/\${p#'~/'} ;; esac
|
|
2537
|
+
if [ -d "$p" ]; then COMPREPLY[\${#COMPREPLY[@]}]="$q/"; else COMPREPLY[\${#COMPREPLY[@]}]="$q "; fi
|
|
2538
|
+
done <<XCLAUDE_EOF
|
|
2539
|
+
$rest
|
|
2540
|
+
XCLAUDE_EOF
|
|
2541
|
+
;;
|
|
2542
|
+
*)
|
|
2543
|
+
while IFS= read -r line; do
|
|
2544
|
+
[ -n "$line" ] || continue
|
|
2545
|
+
if [ "$directive" = nospace ]; then COMPREPLY[\${#COMPREPLY[@]}]="$line"; else COMPREPLY[\${#COMPREPLY[@]}]="$line "; fi
|
|
2546
|
+
done <<XCLAUDE_EOF
|
|
2547
|
+
$rest
|
|
2548
|
+
XCLAUDE_EOF
|
|
2549
|
+
;;
|
|
2550
|
+
esac
|
|
2551
|
+
return 0
|
|
2552
|
+
}
|
|
2553
|
+
complete -o nospace -F _xclaude_complete xclaude
|
|
2554
|
+
${guard ? `
|
|
2555
|
+
${GUARD}` : ""}`;
|
|
2556
|
+
}
|
|
2557
|
+
function initZsh(version, guard) {
|
|
2558
|
+
return `${header("zsh", version)}command -v xclaude >/dev/null 2>&1 || return 0
|
|
2559
|
+
|
|
2560
|
+
_xclaude_complete() {
|
|
2561
|
+
local out directive line
|
|
2562
|
+
local -a lines vals descs
|
|
2563
|
+
out=$(xclaude __complete zsh $(( CURRENT - 1 )) "\${words[@]}" 2>/dev/null) || return 1
|
|
2564
|
+
lines=("\${(@f)out}")
|
|
2565
|
+
directive=\${lines[1]}
|
|
2566
|
+
shift lines
|
|
2567
|
+
case $directive in
|
|
2568
|
+
files) compset -P '*='; _files ;;
|
|
2569
|
+
dirs) compset -P '*='; _files -/ ;;
|
|
2570
|
+
*)
|
|
2571
|
+
for line in "\${lines[@]}"; do
|
|
2572
|
+
[[ -n $line ]] || continue
|
|
2573
|
+
vals+=("\${line%%$'\\t'*}")
|
|
2574
|
+
if [[ $line == *$'\\t'* ]]; then
|
|
2575
|
+
descs+=("\${line%%$'\\t'*} -- \${line#*$'\\t'}")
|
|
2576
|
+
else
|
|
2577
|
+
descs+=("$line")
|
|
2578
|
+
fi
|
|
2579
|
+
done
|
|
2580
|
+
(( \${#vals} )) || return 1
|
|
2581
|
+
if [[ $directive == nospace ]]; then
|
|
2582
|
+
compadd -S '' -l -d descs -a vals
|
|
2583
|
+
else
|
|
2584
|
+
compadd -l -d descs -a vals
|
|
2585
|
+
fi
|
|
2586
|
+
;;
|
|
2587
|
+
esac
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
_xclaude_register() {
|
|
2591
|
+
add-zsh-hook -d precmd _xclaude_register
|
|
2592
|
+
(( $+functions[compdef] )) || { autoload -Uz compinit && compinit -i; }
|
|
2593
|
+
compdef _xclaude_complete xclaude
|
|
2594
|
+
}
|
|
2595
|
+
autoload -Uz add-zsh-hook
|
|
2596
|
+
add-zsh-hook precmd _xclaude_register
|
|
2597
|
+
${guard ? `
|
|
2598
|
+
${GUARD}` : ""}`;
|
|
2599
|
+
}
|
|
2600
|
+
function initFile(paths, shell) {
|
|
2601
|
+
return path15.join(paths.shell, `init.${shell}`);
|
|
2602
|
+
}
|
|
2603
|
+
function writeInitFiles(paths, config, version) {
|
|
2604
|
+
mkdirPrivate(paths.shell);
|
|
2605
|
+
writeFileAtomic(initFile(paths, "bash"), initBash(version, config.guard), { mode: 420 });
|
|
2606
|
+
writeFileAtomic(initFile(paths, "zsh"), initZsh(version, config.guard), { mode: 420 });
|
|
2607
|
+
}
|
|
2608
|
+
function refreshInitFiles(paths, config, version) {
|
|
2609
|
+
if (fs20.existsSync(paths.shell)) writeInitFiles(paths, config, version);
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// package.json
|
|
2613
|
+
var package_default = { version: "0.1.2" };
|
|
2614
|
+
|
|
2615
|
+
// src/version.ts
|
|
2616
|
+
var VERSION = package_default.version;
|
|
2617
|
+
|
|
2618
|
+
// src/commands/guard.ts
|
|
2619
|
+
function guardCommand(ctx, config, args) {
|
|
2620
|
+
const [sub = "status", ...rest] = args;
|
|
2621
|
+
if (rest.length || !["on", "off", "status"].includes(sub)) throw new UsageError("usage: xclaude guard on|off|status");
|
|
2622
|
+
const rcFiles = installedIn(ctx.env, ctx.paths.home).map((f) => tildify(f, ctx.paths.home));
|
|
2623
|
+
if (sub === "status") {
|
|
2624
|
+
ctx.io.out(`guard: ${config.guard ? "on" : "off"}
|
|
2625
|
+
`);
|
|
2626
|
+
ctx.io.out(`shell integration: ${rcFiles.length ? `installed in ${rcFiles.join(", ")}` : "not installed (xclaude shell install)"}
|
|
2627
|
+
`);
|
|
2628
|
+
return EXIT_OK;
|
|
2629
|
+
}
|
|
2630
|
+
config.guard = sub === "on";
|
|
2631
|
+
saveConfig(ctx.paths, config);
|
|
2632
|
+
writeInitFiles(ctx.paths, config, VERSION);
|
|
2633
|
+
ctx.io.err(
|
|
2634
|
+
`xclaude: guard ${sub}: bare \`claude\` ${config.guard ? "refuses to run" : "works again"} in new shells (or after: exec $SHELL)
|
|
2635
|
+
`
|
|
2636
|
+
);
|
|
2637
|
+
if (!rcFiles.length) ctx.io.err("xclaude: the shell block isn't installed yet, so this has no effect until you run: xclaude shell install\n");
|
|
2638
|
+
return EXIT_OK;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// src/commands/help.ts
|
|
2642
|
+
var MAIN_HELP = `xclaude \u2014 run Claude Code under several claude.ai logins at once
|
|
2643
|
+
|
|
2644
|
+
Usage:
|
|
2645
|
+
xclaude <account> [claude args\u2026] Claude Code as that account
|
|
2646
|
+
xclaude [claude flags\u2026] the same, after picking the account
|
|
2647
|
+
xclaude <command> [args\u2026] one of the commands below
|
|
2648
|
+
|
|
2649
|
+
Everything after the account goes to claude, flags and subcommands alike, as in
|
|
2650
|
+
xclaude acme -c --chrome or xclaude acme auth status.
|
|
2651
|
+
|
|
2652
|
+
Commands:
|
|
2653
|
+
add <name> add an account, then log in with /login
|
|
2654
|
+
ls list accounts: login, defaults, config dir
|
|
2655
|
+
set <name> change an account's defaults
|
|
2656
|
+
set main --enable|--disable use the login in ~/.claude as the account "main"
|
|
2657
|
+
rm <name> remove an account and log it out
|
|
2658
|
+
rm --leftovers delete the folders removed accounts left behind
|
|
2659
|
+
tmux new|attach|ls|kill run Claude Code in tmux sessions, by label
|
|
2660
|
+
shell install|uninstall completion (and the guard) in ~/.zshrc, ~/.bashrc
|
|
2661
|
+
shell completion bash|zsh print the completion script to load elsewhere
|
|
2662
|
+
guard on|off|status make bare \`claude\` refuse to run in your shells
|
|
2663
|
+
doctor [--fix] check links, logins and setup; --fix repairs
|
|
2664
|
+
help [command] a command's options; -v prints the version
|
|
2665
|
+
|
|
2666
|
+
Conversations, prompt history, memory and personal content (skills, agents,
|
|
2667
|
+
commands, plugins\u2026) are shared by every account, so \`xclaude other -c\`
|
|
2668
|
+
continues the last conversation in this folder, whichever account it ran on.
|
|
2669
|
+
`;
|
|
2670
|
+
var COMMAND_HELP = {
|
|
2671
|
+
add: `Usage: xclaude add <name> [--model M] [--effort E] [--args "\u2026"]
|
|
2672
|
+
|
|
2673
|
+
Creates the account, links it to the shared ~/.claude, then starts Claude Code
|
|
2674
|
+
in it so you can go through the first-run screens and log in with /login.
|
|
2675
|
+
|
|
2676
|
+
--model M default model, e.g. opus or sonnet
|
|
2677
|
+
--effort E default effort: low, medium, high, xhigh, max or ultracode
|
|
2678
|
+
--args "\u2026" extra Claude Code arguments for every launch, split like a
|
|
2679
|
+
shell line
|
|
2680
|
+
|
|
2681
|
+
Names use lowercase letters, digits and dashes, and start with a letter.
|
|
2682
|
+
`,
|
|
2683
|
+
rm: `Usage: xclaude rm <name> [--keep-login] [-y]
|
|
2684
|
+
xclaude rm --leftovers [-y]
|
|
2685
|
+
|
|
2686
|
+
Removes an account: stops its background sessions, logs it out, and deletes its
|
|
2687
|
+
login, settings and caches. Shared conversations and content stay in ~/.claude.
|
|
2688
|
+
|
|
2689
|
+
Its folder keeps only its links into ~/.claude, so its old conversations can
|
|
2690
|
+
still open their saved long outputs. Run rm on the name again to delete that
|
|
2691
|
+
leftover folder, or --leftovers to delete every one, after a confirmation.
|
|
2692
|
+
|
|
2693
|
+
--keep-login don't log the account out
|
|
2694
|
+
--leftovers delete the leftover folders of all removed accounts
|
|
2695
|
+
-y don't ask for confirmation
|
|
2696
|
+
`,
|
|
2697
|
+
ls: `Usage: xclaude ls
|
|
2698
|
+
|
|
2699
|
+
Lists the accounts with their email, organization and login status, their
|
|
2700
|
+
defaults, and each one's config dir (the CLAUDE_CONFIG_DIR to use elsewhere,
|
|
2701
|
+
e.g. in an editor's environment settings).
|
|
2702
|
+
`,
|
|
2703
|
+
set: `Usage: xclaude set <name> [--model M] [--effort E] [--args "\u2026"]
|
|
2704
|
+
[--unset model|effort|args]
|
|
2705
|
+
xclaude set main --enable|--disable
|
|
2706
|
+
|
|
2707
|
+
Changes an account's launch defaults; with no options, prints them.
|
|
2708
|
+
Arguments typed at launch always win over these defaults.
|
|
2709
|
+
|
|
2710
|
+
--model M default model
|
|
2711
|
+
--effort E default effort: low, medium, high, xhigh, max or ultracode
|
|
2712
|
+
--args "\u2026" extra Claude Code arguments, split like a shell line
|
|
2713
|
+
--unset KEY clear model, effort or args
|
|
2714
|
+
--enable (main only) use the login in ~/.claude as the account "main"
|
|
2715
|
+
--disable (main only) turn it off again
|
|
2716
|
+
`,
|
|
2717
|
+
tmux: `Usage: xclaude tmux new <label> [--dir <path>] [--detach] [--empty]
|
|
2718
|
+
[<account> [claude args\u2026]]
|
|
2719
|
+
xclaude tmux attach [<label>]
|
|
2720
|
+
xclaude tmux ls
|
|
2721
|
+
xclaude tmux kill <label>
|
|
2722
|
+
|
|
2723
|
+
new create session <label> running Claude Code on <account> (the picker
|
|
2724
|
+
opens without one), then attach to it
|
|
2725
|
+
--dir <path> working directory (default: the current one)
|
|
2726
|
+
--detach don't attach
|
|
2727
|
+
--empty just a shell, no Claude Code
|
|
2728
|
+
attach attach to a session; without a label, pick one
|
|
2729
|
+
ls list sessions with their account, directory and Claude's state
|
|
2730
|
+
kill end a session
|
|
2731
|
+
|
|
2732
|
+
Bare \`xclaude tmux\` is \`xclaude tmux ls\`. Needs tmux 3.0 or later.
|
|
2733
|
+
`,
|
|
2734
|
+
shell: `Usage: xclaude shell install|uninstall [--bash] [--zsh]
|
|
2735
|
+
xclaude shell completion bash|zsh
|
|
2736
|
+
|
|
2737
|
+
install add a small block to ~/.zshrc (under $ZDOTDIR when set) and/or
|
|
2738
|
+
~/.bashrc, whichever exist, that loads completion and, when on,
|
|
2739
|
+
the guard; on macOS also to a ~/.bash_profile that doesn't load
|
|
2740
|
+
~/.bashrc
|
|
2741
|
+
uninstall remove that block
|
|
2742
|
+
completion print the completion script for a shell
|
|
2743
|
+
|
|
2744
|
+
Open a new shell (or run \`exec $SHELL\`) afterwards.
|
|
2745
|
+
`,
|
|
2746
|
+
guard: `Usage: xclaude guard on|off|status
|
|
2747
|
+
|
|
2748
|
+
When on, bare \`claude\` refuses to run in interactive shells and points to
|
|
2749
|
+
xclaude. It still works inside Claude Code sessions, in anything xclaude
|
|
2750
|
+
started, and whenever xclaude isn't installed. Needs \`xclaude shell install\`.
|
|
2751
|
+
`,
|
|
2752
|
+
doctor: `Usage: xclaude doctor [--fix]
|
|
2753
|
+
|
|
2754
|
+
Checks tools, config, every account's links and login, the shell setup,
|
|
2755
|
+
machine-managed settings and the environment. Exits non-zero if something is
|
|
2756
|
+
wrong.
|
|
2757
|
+
|
|
2758
|
+
--fix repair links, merge left-behind directories, fix CLAUDE.md stubs
|
|
2759
|
+
`,
|
|
2760
|
+
help: `Usage: xclaude help [command]
|
|
2761
|
+
`
|
|
2762
|
+
};
|
|
2763
|
+
function helpText(topic) {
|
|
2764
|
+
if (topic === void 0) return MAIN_HELP;
|
|
2765
|
+
const text = COMMAND_HELP[topic];
|
|
2766
|
+
if (!text) throw new UsageError(`no help for "${topic}"; commands: ${Object.keys(COMMAND_HELP).join(", ")}`);
|
|
2767
|
+
return text;
|
|
2768
|
+
}
|
|
2769
|
+
function wantsHelp(args) {
|
|
2770
|
+
return args[0] === "-h" || args[0] === "--help";
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// src/shell/complete.ts
|
|
2774
|
+
import fs22 from "node:fs";
|
|
2775
|
+
import path17 from "node:path";
|
|
2776
|
+
|
|
2777
|
+
// src/claude/help.ts
|
|
2778
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
2779
|
+
import fs21 from "node:fs";
|
|
2780
|
+
import path16 from "node:path";
|
|
2781
|
+
var FALLBACK_SUBCOMMANDS = ["auth", "mcp", "plugin", "agents", "daemon", "doctor", "update", "install", "setup-token"];
|
|
2782
|
+
var opt = (names, value, placeholder, choices) => ({
|
|
2783
|
+
names,
|
|
2784
|
+
value,
|
|
2785
|
+
...placeholder ? { placeholder } : {},
|
|
2786
|
+
...choices ? { choices } : {},
|
|
2787
|
+
description: ""
|
|
2788
|
+
});
|
|
2789
|
+
var FALLBACK_HELP = {
|
|
2790
|
+
subcommands: [...FALLBACK_SUBCOMMANDS],
|
|
2791
|
+
options: [
|
|
2792
|
+
opt(["--add-dir"], "variadic", "directories"),
|
|
2793
|
+
opt(["--agent"], "required", "agent"),
|
|
2794
|
+
opt(["--agents"], "required", "json-or-file"),
|
|
2795
|
+
opt(["--allowedTools", "--allowed-tools"], "variadic", "tools"),
|
|
2796
|
+
opt(["--append-system-prompt"], "required", "prompt"),
|
|
2797
|
+
opt(["--chrome"], "none"),
|
|
2798
|
+
opt(["-c", "--continue"], "none"),
|
|
2799
|
+
opt(["--dangerously-skip-permissions"], "none"),
|
|
2800
|
+
opt(["-d", "--debug"], "optional", "filter"),
|
|
2801
|
+
opt(["--debug-file"], "required", "path"),
|
|
2802
|
+
opt(["--disallowedTools", "--disallowed-tools"], "variadic", "tools"),
|
|
2803
|
+
opt(["--effort"], "required", "level"),
|
|
2804
|
+
opt(["--fallback-model"], "required", "model"),
|
|
2805
|
+
opt(["--fork-session"], "none"),
|
|
2806
|
+
opt(["-h", "--help"], "none"),
|
|
2807
|
+
opt(["--ide"], "none"),
|
|
2808
|
+
opt(["--input-format"], "required", "format", ["text", "stream-json"]),
|
|
2809
|
+
opt(["--mcp-config"], "variadic", "configs"),
|
|
2810
|
+
opt(["--model"], "required", "model"),
|
|
2811
|
+
opt(["-n", "--name"], "required", "name"),
|
|
2812
|
+
opt(["--no-chrome"], "none"),
|
|
2813
|
+
opt(["--output-format"], "required", "format", ["text", "json", "stream-json"]),
|
|
2814
|
+
opt(["--permission-mode"], "required", "mode", ["acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan"]),
|
|
2815
|
+
opt(["--plugin-dir"], "required", "path"),
|
|
2816
|
+
opt(["-p", "--print"], "none"),
|
|
2817
|
+
opt(["-r", "--resume"], "optional", "value"),
|
|
2818
|
+
opt(["--session-id"], "required", "uuid"),
|
|
2819
|
+
opt(["--settings"], "required", "file-or-json"),
|
|
2820
|
+
opt(["--strict-mcp-config"], "none"),
|
|
2821
|
+
opt(["--system-prompt"], "required", "prompt"),
|
|
2822
|
+
opt(["--tools"], "variadic", "tools"),
|
|
2823
|
+
opt(["--verbose"], "none"),
|
|
2824
|
+
opt(["-v", "--version"], "none"),
|
|
2825
|
+
opt(["-w", "--worktree"], "optional", "name")
|
|
2826
|
+
]
|
|
2827
|
+
};
|
|
2828
|
+
function valueKind(placeholder) {
|
|
2829
|
+
if (!placeholder) return "none";
|
|
2830
|
+
const variadic = placeholder.includes("...");
|
|
2831
|
+
if (placeholder.startsWith("<")) return variadic ? "variadic" : "required";
|
|
2832
|
+
return "optional";
|
|
2833
|
+
}
|
|
2834
|
+
function parseChoices(description) {
|
|
2835
|
+
const m = /\(choices: ([^)]*)\)/.exec(description);
|
|
2836
|
+
if (!m) return void 0;
|
|
2837
|
+
const choices = [...m[1].matchAll(/"([^"]*)"/g)].map((x) => x[1]);
|
|
2838
|
+
return choices.length ? choices : void 0;
|
|
2839
|
+
}
|
|
2840
|
+
function parseHelp(text) {
|
|
2841
|
+
const options = [];
|
|
2842
|
+
const subcommands = [];
|
|
2843
|
+
let section = "";
|
|
2844
|
+
let current = null;
|
|
2845
|
+
const finish = () => {
|
|
2846
|
+
if (!current) return;
|
|
2847
|
+
current.description = current.description.replace(/\s+/g, " ").trim();
|
|
2848
|
+
const choices = parseChoices(current.description);
|
|
2849
|
+
if (choices) current.choices = choices;
|
|
2850
|
+
options.push(current);
|
|
2851
|
+
current = null;
|
|
2852
|
+
};
|
|
2853
|
+
for (const line of text.split("\n")) {
|
|
2854
|
+
const header2 = /^([A-Z][A-Za-z ]*):\s*$/.exec(line);
|
|
2855
|
+
if (header2) {
|
|
2856
|
+
finish();
|
|
2857
|
+
section = header2[1];
|
|
2858
|
+
continue;
|
|
2859
|
+
}
|
|
2860
|
+
if (section === "Options") {
|
|
2861
|
+
const m = /^ {2}(-\S.*)$/.exec(line);
|
|
2862
|
+
if (m) {
|
|
2863
|
+
finish();
|
|
2864
|
+
const [spec = "", ...rest] = m[1].split(/\s{2,}/);
|
|
2865
|
+
const tokens = spec.split(/,?\s+/).filter(Boolean);
|
|
2866
|
+
const names = tokens.filter((t) => t.startsWith("-")).map((t) => t.replace(/,$/, ""));
|
|
2867
|
+
const placeholder = tokens.find((t) => t.startsWith("<") || t.startsWith("["));
|
|
2868
|
+
current = {
|
|
2869
|
+
names,
|
|
2870
|
+
value: valueKind(placeholder),
|
|
2871
|
+
...placeholder ? { placeholder: placeholder.replace(/^[<[]|\.{3}|[>\]]$/g, "") } : {},
|
|
2872
|
+
description: rest.join(" ")
|
|
2873
|
+
};
|
|
2874
|
+
} else if (current && /^\s{3,}\S/.test(line)) {
|
|
2875
|
+
current.description += ` ${line.trim()}`;
|
|
2876
|
+
} else if (line.trim() === "") {
|
|
2877
|
+
finish();
|
|
2878
|
+
}
|
|
2879
|
+
} else if (section === "Commands") {
|
|
2880
|
+
const m = /^ {2}([a-z][\w|-]*)/.exec(line);
|
|
2881
|
+
if (m) subcommands.push(...m[1].split("|"));
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
finish();
|
|
2885
|
+
return { options, subcommands };
|
|
2886
|
+
}
|
|
2887
|
+
function findOption(help, arg) {
|
|
2888
|
+
const flag = arg.startsWith("--") ? arg.split("=")[0] : arg;
|
|
2889
|
+
return help.options.find((o) => o.names.includes(flag));
|
|
2890
|
+
}
|
|
2891
|
+
function longName(o) {
|
|
2892
|
+
return o.names.find((n) => n.startsWith("--")) ?? o.names[0];
|
|
2893
|
+
}
|
|
2894
|
+
function isSubcommand(word, help) {
|
|
2895
|
+
if (!word || word.startsWith("-")) return false;
|
|
2896
|
+
return FALLBACK_SUBCOMMANDS.includes(word) || Boolean(help?.subcommands.includes(word));
|
|
2897
|
+
}
|
|
2898
|
+
function loadHelp(claude, cacheDir, env) {
|
|
2899
|
+
const real = realpathOrNull(claude) ?? claude;
|
|
2900
|
+
let st;
|
|
2901
|
+
try {
|
|
2902
|
+
st = fs21.statSync(real);
|
|
2903
|
+
} catch {
|
|
2904
|
+
return FALLBACK_HELP;
|
|
2905
|
+
}
|
|
2906
|
+
const key = { path: real, mtimeMs: st.mtimeMs, size: st.size };
|
|
2907
|
+
const file = path16.join(cacheDir, "claude-help.json");
|
|
2908
|
+
try {
|
|
2909
|
+
const cached = JSON.parse(readFileOrNull(file) ?? "null");
|
|
2910
|
+
if (cached && cached.key.path === key.path && cached.key.mtimeMs === key.mtimeMs && cached.key.size === key.size) {
|
|
2911
|
+
return cached.help;
|
|
2912
|
+
}
|
|
2913
|
+
} catch {
|
|
2914
|
+
}
|
|
2915
|
+
const res = spawnSync3(...shellWay(claude, ["--help"]), {
|
|
2916
|
+
env,
|
|
2917
|
+
encoding: "utf8",
|
|
2918
|
+
timeout: 15e3,
|
|
2919
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2920
|
+
});
|
|
2921
|
+
let help = res.status === 0 && res.stdout ? parseHelp(res.stdout) : FALLBACK_HELP;
|
|
2922
|
+
if (help.options.length < 5) help = FALLBACK_HELP;
|
|
2923
|
+
try {
|
|
2924
|
+
mkdirPrivate(cacheDir);
|
|
2925
|
+
writeFileAtomic(file, `${JSON.stringify({ key, help })}
|
|
2926
|
+
`, { mode: 384 });
|
|
2927
|
+
} catch {
|
|
2928
|
+
}
|
|
2929
|
+
return help;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// src/tmux/sessions.ts
|
|
2933
|
+
var LABEL = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
2934
|
+
var SEP = "|";
|
|
2935
|
+
var FORMAT = ["#{session_id}", "#{@xclaude}", "#{session_attached}", "#{session_created}", "#{@xclaude_label}", "#{@xclaude_account}", "#{@xclaude_dir}"].join(SEP);
|
|
2936
|
+
function sessionName(account, label) {
|
|
2937
|
+
return account ? `xclaude-${account}_${label}` : `xclaude--${label}`;
|
|
2938
|
+
}
|
|
2939
|
+
function listSessions(tmux) {
|
|
2940
|
+
const res = tmux.run(["list-sessions", "-F", FORMAT]);
|
|
2941
|
+
if (res.code !== 0) {
|
|
2942
|
+
if (isNoServer(res.stderr)) return [];
|
|
2943
|
+
throw new XError(`tmux list-sessions failed: ${res.stderr.trim()}`);
|
|
2944
|
+
}
|
|
2945
|
+
const sessions = [];
|
|
2946
|
+
for (const line of res.stdout.split("\n")) {
|
|
2947
|
+
const parts = line.split(SEP);
|
|
2948
|
+
if (parts.length < 7 || parts[1] !== "1") continue;
|
|
2949
|
+
const [id = "", , attached = "0", created = "0", label = "", account = "", ...dir] = parts;
|
|
2950
|
+
sessions.push({ id, label, account: account || null, dir: dir.join(SEP), attached: Number(attached), created: Number(created) });
|
|
2951
|
+
}
|
|
2952
|
+
return sessions.sort((a, b) => a.label.localeCompare(b.label));
|
|
2953
|
+
}
|
|
2954
|
+
function findByLabel(sessions, label) {
|
|
2955
|
+
return sessions.find((s) => s.label === label);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// src/shell/complete.ts
|
|
2959
|
+
var MODEL_ALIASES = ["default", "best", "fable", "opus", "sonnet", "haiku", "opus[1m]", "sonnet[1m]", "opusplan"];
|
|
2960
|
+
var COMMAND_DESCRIPTIONS = {
|
|
2961
|
+
add: "add an account and log in",
|
|
2962
|
+
rm: "remove an account",
|
|
2963
|
+
ls: "list accounts and logins",
|
|
2964
|
+
set: "change an account's defaults",
|
|
2965
|
+
tmux: "Claude Code in tmux sessions",
|
|
2966
|
+
doctor: "check and repair",
|
|
2967
|
+
shell: "shell integration",
|
|
2968
|
+
guard: "disable bare claude",
|
|
2969
|
+
help: "help for a command"
|
|
2970
|
+
};
|
|
2971
|
+
var none = () => ({ directive: "default", items: [] });
|
|
2972
|
+
var words = (values, describe) => ({
|
|
2973
|
+
directive: "default",
|
|
2974
|
+
items: values.map((value) => ({ value, ...describe?.(value) ? { description: describe(value) } : {} }))
|
|
2975
|
+
});
|
|
2976
|
+
function logicalWords(shell, words2, cword) {
|
|
2977
|
+
let end = cword;
|
|
2978
|
+
let cur = words2[cword] ?? "";
|
|
2979
|
+
if (shell === "bash") {
|
|
2980
|
+
if (cur === "=" && cword > 0) {
|
|
2981
|
+
cur = `${words2[cword - 1]}=`;
|
|
2982
|
+
end = cword - 1;
|
|
2983
|
+
} else if (words2[cword - 1] === "=" && cword > 1) {
|
|
2984
|
+
cur = `${words2[cword - 2]}=${cur}`;
|
|
2985
|
+
end = cword - 2;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
const prior = [];
|
|
2989
|
+
const before = words2.slice(0, end);
|
|
2990
|
+
for (let i = 0; i < before.length; i++) {
|
|
2991
|
+
if (shell === "bash" && before[i] === "=" && prior.length && i + 1 < before.length) {
|
|
2992
|
+
prior[prior.length - 1] += `=${before[++i]}`;
|
|
2993
|
+
} else {
|
|
2994
|
+
prior.push(before[i]);
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
return { prior, cur };
|
|
2998
|
+
}
|
|
2999
|
+
function suggestLabel(cwd) {
|
|
3000
|
+
const label = path17.basename(cwd).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
|
|
3001
|
+
return label || null;
|
|
3002
|
+
}
|
|
3003
|
+
function resumeCandidates(store, cwd) {
|
|
3004
|
+
const dir = path17.join(store, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
|
|
3005
|
+
let files;
|
|
3006
|
+
try {
|
|
3007
|
+
files = fs22.readdirSync(dir).filter((n) => n.endsWith(".jsonl")).map((n) => ({ id: n.slice(0, -".jsonl".length), file: path17.join(dir, n), mtime: fs22.statSync(path17.join(dir, n)).mtimeMs }));
|
|
3008
|
+
} catch {
|
|
3009
|
+
return [];
|
|
3010
|
+
}
|
|
3011
|
+
return files.sort((a, b) => b.mtime - a.mtime).slice(0, 30).map((f) => {
|
|
3012
|
+
const title = sessionTitle(f.file);
|
|
3013
|
+
return title ? { value: f.id, description: title } : { value: f.id };
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
function sessionTitle(file) {
|
|
3017
|
+
let head;
|
|
3018
|
+
try {
|
|
3019
|
+
const fd = fs22.openSync(file, "r");
|
|
3020
|
+
try {
|
|
3021
|
+
const buf = Buffer.alloc(16384);
|
|
3022
|
+
head = buf.subarray(0, fs22.readSync(fd, buf, 0, buf.length, 0)).toString("utf8");
|
|
3023
|
+
} finally {
|
|
3024
|
+
fs22.closeSync(fd);
|
|
3025
|
+
}
|
|
3026
|
+
} catch {
|
|
3027
|
+
return null;
|
|
3028
|
+
}
|
|
3029
|
+
let prompt = null;
|
|
3030
|
+
for (const line of head.split("\n")) {
|
|
3031
|
+
let e;
|
|
3032
|
+
try {
|
|
3033
|
+
e = JSON.parse(line);
|
|
3034
|
+
} catch {
|
|
3035
|
+
continue;
|
|
3036
|
+
}
|
|
3037
|
+
if (typeof e.customTitle === "string" && e.customTitle) return clip(e.customTitle);
|
|
3038
|
+
if (e.type === "summary" && typeof e.summary === "string") return clip(e.summary);
|
|
3039
|
+
if (!prompt && e.type === "user") {
|
|
3040
|
+
const content = e.message?.content;
|
|
3041
|
+
if (typeof content === "string") prompt = content;
|
|
3042
|
+
else if (Array.isArray(content)) {
|
|
3043
|
+
const text = content.find((c) => c?.type === "text")?.text;
|
|
3044
|
+
if (typeof text === "string") prompt = text;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
return prompt ? clip(prompt) : null;
|
|
3049
|
+
}
|
|
3050
|
+
function clip(s) {
|
|
3051
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
3052
|
+
return one.length > 60 ? `${one.slice(0, 59)}\u2026` : one;
|
|
3053
|
+
}
|
|
3054
|
+
var Completer = class {
|
|
3055
|
+
ctx;
|
|
3056
|
+
config;
|
|
3057
|
+
help = null;
|
|
3058
|
+
constructor(ctx, config) {
|
|
3059
|
+
this.ctx = ctx;
|
|
3060
|
+
this.config = config;
|
|
3061
|
+
}
|
|
3062
|
+
/** Claude Code's parsed --help: the cache, or a one-time parse; the built-in lists without ~/.xclaude. */
|
|
3063
|
+
claudeHelp() {
|
|
3064
|
+
if (this.help) return this.help;
|
|
3065
|
+
this.help = FALLBACK_HELP;
|
|
3066
|
+
if (fs22.existsSync(this.ctx.paths.xhome)) {
|
|
3067
|
+
try {
|
|
3068
|
+
const claude = findClaude(this.ctx.env, this.config, this.ctx.paths.home, this.ctx.selfPath);
|
|
3069
|
+
this.help = loadHelp(claude, this.ctx.paths.cache, this.ctx.env);
|
|
3070
|
+
} catch {
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
return this.help;
|
|
3074
|
+
}
|
|
3075
|
+
describeIdentity(name) {
|
|
3076
|
+
const d = defaultsOf(this.config, name);
|
|
3077
|
+
const parts = [name === MAIN ? "the ~/.claude login" : "account", d ? describeDefaults(d) : ""].filter(Boolean);
|
|
3078
|
+
return parts.join(" \xB7 ");
|
|
3079
|
+
}
|
|
3080
|
+
complete(args, cur) {
|
|
3081
|
+
const ids = identities(this.config);
|
|
3082
|
+
if (args.length === 0) {
|
|
3083
|
+
if (cur.startsWith("-")) return { directive: "default", items: [...this.claudeFlags(), { value: "--help" }, { value: "--version" }] };
|
|
3084
|
+
return {
|
|
3085
|
+
directive: "default",
|
|
3086
|
+
items: [
|
|
3087
|
+
...COMMANDS.map((c) => ({ value: c, description: COMMAND_DESCRIPTIONS[c] })),
|
|
3088
|
+
...ids.map((n) => ({ value: n, description: this.describeIdentity(n) }))
|
|
3089
|
+
]
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
const [first = "", ...rest] = args;
|
|
3093
|
+
if (ids.includes(first)) return this.claudeArgs(rest, cur);
|
|
3094
|
+
if (first.startsWith("-")) return this.claudeArgs(args, cur);
|
|
3095
|
+
switch (first) {
|
|
3096
|
+
case "add":
|
|
3097
|
+
return this.own(rest, cur, { "--model": "model", "--effort": "effort", "--args": "text" }, 1, () => []);
|
|
3098
|
+
case "rm":
|
|
3099
|
+
if (rest.includes("--leftovers")) return this.own(rest, cur, { "-y": null }, 0, () => []);
|
|
3100
|
+
return this.own(
|
|
3101
|
+
rest,
|
|
3102
|
+
cur,
|
|
3103
|
+
{ "--keep-login": null, "--leftovers": null, "-y": null },
|
|
3104
|
+
1,
|
|
3105
|
+
() => [...Object.keys(this.config.accounts), ...leftoverNames(this.ctx.paths, this.config)],
|
|
3106
|
+
(n) => this.config.accounts[n] ? this.describeIdentity(n) : "leftover folder of a removed account"
|
|
3107
|
+
);
|
|
3108
|
+
case "set":
|
|
3109
|
+
return this.own(
|
|
3110
|
+
rest,
|
|
3111
|
+
cur,
|
|
3112
|
+
{ "--model": "model", "--effort": "effort", "--args": "text", "--unset": "unset", "--enable": null, "--disable": null },
|
|
3113
|
+
1,
|
|
3114
|
+
() => [...Object.keys(this.config.accounts), MAIN]
|
|
3115
|
+
);
|
|
3116
|
+
case "tmux":
|
|
3117
|
+
return this.tmux(rest, cur);
|
|
3118
|
+
case "shell":
|
|
3119
|
+
if (rest.length === 0) return words(["install", "uninstall", "completion"]);
|
|
3120
|
+
if (rest[0] === "completion") return rest.length === 1 ? words(["bash", "zsh"]) : none();
|
|
3121
|
+
return rest[0] === "install" || rest[0] === "uninstall" ? words(["--bash", "--zsh"]) : none();
|
|
3122
|
+
case "guard":
|
|
3123
|
+
return rest.length === 0 ? words(["on", "off", "status"]) : none();
|
|
3124
|
+
case "doctor":
|
|
3125
|
+
return words(["--fix"]);
|
|
3126
|
+
case "help":
|
|
3127
|
+
return rest.length === 0 ? words(COMMANDS, (c) => COMMAND_DESCRIPTIONS[c]) : none();
|
|
3128
|
+
default:
|
|
3129
|
+
return none();
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
/** xclaude's own options: flags, their values, and up to `max` positionals. */
|
|
3133
|
+
own(rest, cur, flags, max, positional, describe = (n) => n === MAIN || this.config.accounts[n] ? this.describeIdentity(n) : void 0) {
|
|
3134
|
+
const eq = cur.startsWith("--") ? cur.indexOf("=") : -1;
|
|
3135
|
+
if (eq > 0) return this.ownValues(flags[cur.slice(0, eq)] ?? null, cur.slice(0, eq + 1));
|
|
3136
|
+
const prev = rest[rest.length - 1];
|
|
3137
|
+
if (prev !== void 0 && flags[prev]) return this.ownValues(flags[prev], "");
|
|
3138
|
+
if (cur.startsWith("-")) return words(Object.keys(flags));
|
|
3139
|
+
let positionals = 0;
|
|
3140
|
+
for (let i = 0; i < rest.length; i++) {
|
|
3141
|
+
const a = rest[i];
|
|
3142
|
+
if (a.startsWith("-")) {
|
|
3143
|
+
if (flags[a] && !a.includes("=")) i++;
|
|
3144
|
+
} else positionals++;
|
|
3145
|
+
}
|
|
3146
|
+
if (positionals >= max) return none();
|
|
3147
|
+
return words(positional(), describe);
|
|
3148
|
+
}
|
|
3149
|
+
ownValues(kind, prefix) {
|
|
3150
|
+
const values = kind === "model" ? MODEL_ALIASES : kind === "effort" ? EFFORTS : kind === "unset" ? ["model", "effort", "args"] : [];
|
|
3151
|
+
return words(values.map((v) => `${prefix}${v}`));
|
|
3152
|
+
}
|
|
3153
|
+
claudeFlags() {
|
|
3154
|
+
const items = [];
|
|
3155
|
+
for (const o of this.claudeHelp().options) {
|
|
3156
|
+
const description = o.description ? clip(o.description.split(/(?<=\.)\s/)[0]) : void 0;
|
|
3157
|
+
for (const n of o.names) items.push(description ? { value: n, description } : { value: n });
|
|
3158
|
+
}
|
|
3159
|
+
return items;
|
|
3160
|
+
}
|
|
3161
|
+
/** The option waiting for a value: the previous word, or a variadic list still going on. */
|
|
3162
|
+
pendingValue(args) {
|
|
3163
|
+
const help = this.claudeHelp();
|
|
3164
|
+
const last = args[args.length - 1];
|
|
3165
|
+
if (last?.startsWith("-") && !last.includes("=")) {
|
|
3166
|
+
const spec = findOption(help, last);
|
|
3167
|
+
return spec && spec.value !== "none" ? spec : null;
|
|
3168
|
+
}
|
|
3169
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
3170
|
+
const a = args[i];
|
|
3171
|
+
if (a.startsWith("-")) {
|
|
3172
|
+
const spec = a.includes("=") ? void 0 : findOption(help, a);
|
|
3173
|
+
return spec?.value === "variadic" ? spec : null;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
return null;
|
|
3177
|
+
}
|
|
3178
|
+
claudeArgs(args, cur) {
|
|
3179
|
+
const help = this.claudeHelp();
|
|
3180
|
+
if (args.length && isSubcommand(args[0], help)) return { directive: "files", items: [] };
|
|
3181
|
+
const eq = cur.startsWith("--") ? cur.indexOf("=") : -1;
|
|
3182
|
+
if (eq > 0) {
|
|
3183
|
+
const spec = findOption(help, cur.slice(0, eq));
|
|
3184
|
+
return spec && spec.value !== "none" ? this.claudeValues(spec, cur.slice(0, eq + 1)) : none();
|
|
3185
|
+
}
|
|
3186
|
+
if (!cur.startsWith("-")) {
|
|
3187
|
+
const pending = this.pendingValue(args);
|
|
3188
|
+
if (pending) return this.claudeValues(pending, "");
|
|
3189
|
+
if (args.length === 0) return words([.../* @__PURE__ */ new Set([...help.subcommands, ...FALLBACK_SUBCOMMANDS])].sort());
|
|
3190
|
+
return none();
|
|
3191
|
+
}
|
|
3192
|
+
return { directive: "default", items: this.claudeFlags() };
|
|
3193
|
+
}
|
|
3194
|
+
claudeValues(spec, prefix) {
|
|
3195
|
+
const name = longName(spec);
|
|
3196
|
+
let values = null;
|
|
3197
|
+
if (name === "--model" || name === "--fallback-model") values = MODEL_ALIASES.map((value) => ({ value }));
|
|
3198
|
+
else if (name === "--effort") values = EFFORTS.map((value) => ({ value }));
|
|
3199
|
+
else if (spec.choices) values = spec.choices.map((value) => ({ value }));
|
|
3200
|
+
else if (name === "--resume") values = resumeCandidates(this.ctx.paths.store, this.ctx.cwd);
|
|
3201
|
+
else if (/dir/i.test(name) || /dir/i.test(spec.placeholder ?? "")) return { directive: "dirs", items: [] };
|
|
3202
|
+
else if (/path|file|config/i.test(spec.placeholder ?? "")) return { directive: "files", items: [] };
|
|
3203
|
+
if (!values) return none();
|
|
3204
|
+
return { directive: "default", items: values.map((v) => ({ ...v, value: `${prefix}${v.value}` })) };
|
|
3205
|
+
}
|
|
3206
|
+
labels() {
|
|
3207
|
+
try {
|
|
3208
|
+
return listSessions(new Tmux(this.ctx.env)).map((s) => s.label);
|
|
3209
|
+
} catch {
|
|
3210
|
+
return [];
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
tmux(rest, cur) {
|
|
3214
|
+
if (rest.length === 0) return words(["new", "attach", "ls", "kill"]);
|
|
3215
|
+
const [sub, ...more] = rest;
|
|
3216
|
+
if (sub === "attach" || sub === "kill") return more.length === 0 ? words(this.labels()) : none();
|
|
3217
|
+
if (sub !== "new") return none();
|
|
3218
|
+
if (more.length === 0) {
|
|
3219
|
+
const label = suggestLabel(this.ctx.cwd);
|
|
3220
|
+
return !cur.startsWith("-") && label ? words([label]) : none();
|
|
3221
|
+
}
|
|
3222
|
+
const after = more.slice(1);
|
|
3223
|
+
if (after[after.length - 1] === "--dir" || cur.startsWith("--dir=")) return { directive: "dirs", items: [] };
|
|
3224
|
+
let i = 0;
|
|
3225
|
+
for (; i < after.length; i++) {
|
|
3226
|
+
if (after[i] === "--dir") i++;
|
|
3227
|
+
else if (!after[i].startsWith("--")) break;
|
|
3228
|
+
}
|
|
3229
|
+
if (i < after.length) return this.claudeArgs(after.slice(i + 1), cur);
|
|
3230
|
+
if (cur.startsWith("-")) return words(["--dir", "--detach", "--empty"]);
|
|
3231
|
+
return words(identities(this.config), (n) => this.describeIdentity(n));
|
|
3232
|
+
}
|
|
3233
|
+
};
|
|
3234
|
+
function complete(ctx, config, shell, cword, allWords) {
|
|
3235
|
+
const { prior, cur } = logicalWords(shell, allWords, cword);
|
|
3236
|
+
const raw = new Completer(ctx, config).complete(prior.slice(1), cur);
|
|
3237
|
+
const items = raw.items.filter((i) => i.value.startsWith(cur));
|
|
3238
|
+
return { completion: { directive: raw.directive, items }, cur };
|
|
3239
|
+
}
|
|
3240
|
+
function formatCompletion(c, shell, cur) {
|
|
3241
|
+
const eq = cur.startsWith("-") ? cur.indexOf("=") : -1;
|
|
3242
|
+
const lines = [c.directive];
|
|
3243
|
+
for (const item of c.items) {
|
|
3244
|
+
const value = shell === "bash" && eq > 0 ? item.value.slice(eq + 1) : item.value;
|
|
3245
|
+
lines.push(shell === "zsh" && item.description ? `${value} ${item.description.replace(/[\t\n]/g, " ")}` : value);
|
|
3246
|
+
}
|
|
3247
|
+
return `${lines.join("\n")}
|
|
3248
|
+
`;
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// src/commands/hidden.ts
|
|
3252
|
+
function quietConfig(ctx) {
|
|
3253
|
+
try {
|
|
3254
|
+
return loadConfig(ctx.paths, { create: false }).config;
|
|
3255
|
+
} catch {
|
|
3256
|
+
return null;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
function completeCommand(ctx, args) {
|
|
3260
|
+
const [shell, cwordArg, ...words2] = args;
|
|
3261
|
+
const cword = Number(cwordArg);
|
|
3262
|
+
if (shell !== "bash" && shell !== "zsh" || !Number.isInteger(cword) || cword < 0) return EXIT_OK;
|
|
3263
|
+
const config = quietConfig(ctx);
|
|
3264
|
+
if (!config) return EXIT_OK;
|
|
3265
|
+
try {
|
|
3266
|
+
const { completion, cur } = complete(ctx, config, shell, cword, words2);
|
|
3267
|
+
ctx.io.out(formatCompletion(completion, shell, cur));
|
|
3268
|
+
} catch {
|
|
3269
|
+
}
|
|
3270
|
+
return EXIT_OK;
|
|
3271
|
+
}
|
|
3272
|
+
function namesCommand(ctx) {
|
|
3273
|
+
const config = quietConfig(ctx);
|
|
3274
|
+
const names = config ? identities(config) : [];
|
|
3275
|
+
if (names.length) ctx.io.out(`${names.join(" ")}
|
|
3276
|
+
`);
|
|
3277
|
+
return EXIT_OK;
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
// src/commands/ls.ts
|
|
3281
|
+
async function lsCommand(ctx, config, args) {
|
|
3282
|
+
if (args.length) throw new UsageError("usage: xclaude ls");
|
|
3283
|
+
const names = identities(config);
|
|
3284
|
+
if (!names.length) {
|
|
3285
|
+
ctx.io.out("No accounts yet. Add one with: xclaude add <name>\n");
|
|
3286
|
+
return EXIT_OK;
|
|
3287
|
+
}
|
|
3288
|
+
let claude = null;
|
|
3289
|
+
try {
|
|
3290
|
+
claude = findClaude(ctx.env, config, ctx.paths.home, ctx.selfPath);
|
|
3291
|
+
} catch (e) {
|
|
3292
|
+
if (!(e instanceof XError)) throw e;
|
|
3293
|
+
ctx.io.err(`xclaude: ${e.message}
|
|
3294
|
+
`);
|
|
3295
|
+
}
|
|
3296
|
+
const logins = await Promise.all(names.map((n) => claude ? authStatus(claude, identityEnv(ctx, n)).then(describeLogin) : "?"));
|
|
3297
|
+
const rows = names.map((n, i) => {
|
|
3298
|
+
const d = defaultsOf(config, n);
|
|
3299
|
+
const dir = n === MAIN ? `${ctx.paths.store} (no CLAUDE_CONFIG_DIR)` : accountDir(ctx.paths, n);
|
|
3300
|
+
return [n, logins[i], d.model ?? DASH, d.effort ?? DASH, d.args.length ? quoteShellWords(d.args) : DASH, dir];
|
|
3301
|
+
});
|
|
3302
|
+
ctx.io.out(formatTable(["NAME", "LOGIN", "MODEL", "EFFORT", "ARGS", "CONFIG DIR"], rows));
|
|
3303
|
+
return EXIT_OK;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// src/core/prompt.ts
|
|
3307
|
+
import fs23 from "node:fs";
|
|
3308
|
+
function confirm(question) {
|
|
3309
|
+
fs23.writeSync(2, `${question} [y/N] `);
|
|
3310
|
+
let fd;
|
|
3311
|
+
try {
|
|
3312
|
+
fd = fs23.openSync("/dev/tty", "r");
|
|
3313
|
+
} catch {
|
|
3314
|
+
return false;
|
|
3315
|
+
}
|
|
3316
|
+
try {
|
|
3317
|
+
const buf = Buffer.alloc(256);
|
|
3318
|
+
const n = fs23.readSync(fd, buf, 0, buf.length, null);
|
|
3319
|
+
const answer = buf.subarray(0, n).toString("utf8").trim().toLowerCase();
|
|
3320
|
+
return answer === "y" || answer === "yes";
|
|
3321
|
+
} catch {
|
|
3322
|
+
return false;
|
|
3323
|
+
} finally {
|
|
3324
|
+
fs23.closeSync(fd);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
// src/core/state.ts
|
|
3329
|
+
var MAX_DIRS = 500;
|
|
3330
|
+
function emptyState() {
|
|
3331
|
+
return { installedVersion: null, lastAccountByDir: {}, lastUsedAccount: null, seenUnknownEntries: {} };
|
|
3332
|
+
}
|
|
3333
|
+
function isRecord(v) {
|
|
3334
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3335
|
+
}
|
|
3336
|
+
function loadState(paths) {
|
|
3337
|
+
let raw;
|
|
3338
|
+
try {
|
|
3339
|
+
raw = JSON.parse(readFileOrNull(paths.state) ?? "{}");
|
|
3340
|
+
} catch {
|
|
3341
|
+
return emptyState();
|
|
3342
|
+
}
|
|
3343
|
+
const state = emptyState();
|
|
3344
|
+
if (!isRecord(raw)) return state;
|
|
3345
|
+
if (typeof raw.installedVersion === "string") state.installedVersion = raw.installedVersion;
|
|
3346
|
+
if (typeof raw.lastUsedAccount === "string") state.lastUsedAccount = raw.lastUsedAccount;
|
|
3347
|
+
if (isRecord(raw.lastAccountByDir)) {
|
|
3348
|
+
for (const [dir, name] of Object.entries(raw.lastAccountByDir)) {
|
|
3349
|
+
if (typeof name === "string") state.lastAccountByDir[dir] = name;
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
if (isRecord(raw.seenUnknownEntries)) {
|
|
3353
|
+
for (const [name, entries] of Object.entries(raw.seenUnknownEntries)) {
|
|
3354
|
+
if (Array.isArray(entries)) state.seenUnknownEntries[name] = entries.filter((e) => typeof e === "string");
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
return state;
|
|
3358
|
+
}
|
|
3359
|
+
function saveState(paths, state) {
|
|
3360
|
+
ensureXHome(paths);
|
|
3361
|
+
writeFileAtomic(paths.state, `${JSON.stringify(state, null, 2)}
|
|
3362
|
+
`, { mode: 384 });
|
|
3363
|
+
}
|
|
3364
|
+
function updateState(paths, change) {
|
|
3365
|
+
const state = loadState(paths);
|
|
3366
|
+
change(state);
|
|
3367
|
+
saveState(paths, state);
|
|
3368
|
+
return state;
|
|
3369
|
+
}
|
|
3370
|
+
function recordUse(state, dir, account) {
|
|
3371
|
+
delete state.lastAccountByDir[dir];
|
|
3372
|
+
state.lastAccountByDir[dir] = account;
|
|
3373
|
+
const dirs = Object.keys(state.lastAccountByDir);
|
|
3374
|
+
for (const old of dirs.slice(0, Math.max(0, dirs.length - MAX_DIRS))) delete state.lastAccountByDir[old];
|
|
3375
|
+
state.lastUsedAccount = account;
|
|
3376
|
+
}
|
|
3377
|
+
function forgetAccount(state, account) {
|
|
3378
|
+
for (const [dir, name] of Object.entries(state.lastAccountByDir)) {
|
|
3379
|
+
if (name === account) delete state.lastAccountByDir[dir];
|
|
3380
|
+
}
|
|
3381
|
+
if (state.lastUsedAccount === account) state.lastUsedAccount = null;
|
|
3382
|
+
delete state.seenUnknownEntries[account];
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
// src/grammar.ts
|
|
3386
|
+
var HIDDEN = ["__complete", "__names"];
|
|
3387
|
+
function isCommand(word) {
|
|
3388
|
+
return COMMANDS.includes(word);
|
|
3389
|
+
}
|
|
3390
|
+
function isHidden(word) {
|
|
3391
|
+
return HIDDEN.includes(word);
|
|
3392
|
+
}
|
|
3393
|
+
function classify(argv, config) {
|
|
3394
|
+
const [first, ...rest] = argv;
|
|
3395
|
+
if (first === void 0) return { kind: "launch", account: null, args: [] };
|
|
3396
|
+
if (first === "-v" || first === "--version") return { kind: "version" };
|
|
3397
|
+
if (first === "-h" || first === "--help") return { kind: "help" };
|
|
3398
|
+
if (first.startsWith("-")) return { kind: "launch", account: null, args: argv };
|
|
3399
|
+
if (isCommand(first)) return { kind: "command", name: first, args: rest };
|
|
3400
|
+
if (isHidden(first)) return { kind: "hidden", name: first, args: rest };
|
|
3401
|
+
if (first === MAIN) {
|
|
3402
|
+
if (!config.main.enabled) throw new XError(`the main identity is disabled; enable it with: xclaude set main --enable`);
|
|
3403
|
+
return { kind: "launch", account: MAIN, args: rest };
|
|
3404
|
+
}
|
|
3405
|
+
if (Object.hasOwn(config.accounts, first)) return { kind: "launch", account: first, args: rest };
|
|
3406
|
+
throw unknownAccount(first, config);
|
|
3407
|
+
}
|
|
3408
|
+
function unknownAccount(word, config) {
|
|
3409
|
+
const names = identities(config);
|
|
3410
|
+
const list = names.length ? `accounts: ${names.join(", ")}` : "no accounts yet; add one with: xclaude add <name>";
|
|
3411
|
+
return new UsageError(`unknown account or command "${word}" (${list})`);
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
// src/commands/rm.ts
|
|
3415
|
+
var USAGE2 = "usage: xclaude rm <name> [--keep-login] [-y], or xclaude rm --leftovers [-y]";
|
|
3416
|
+
async function rmCommand(ctx, config, args, listSessions2 = () => []) {
|
|
3417
|
+
const p = parseOptions(args, { "keep-login": {}, leftovers: {}, yes: { short: "y" } }, "rm");
|
|
3418
|
+
const [name, ...extra] = p.positionals;
|
|
3419
|
+
const yes = Boolean(p.values.yes);
|
|
3420
|
+
if (p.values.leftovers) {
|
|
3421
|
+
if (name) throw new UsageError(USAGE2);
|
|
3422
|
+
return removeLeftovers(ctx, config, null, yes, listSessions2);
|
|
3423
|
+
}
|
|
3424
|
+
if (!name || extra.length) throw new UsageError(USAGE2);
|
|
3425
|
+
if (name === MAIN) throw new UsageError("the main identity isn't removable; turn it off with: xclaude set main --disable");
|
|
3426
|
+
if (!Object.hasOwn(config.accounts, name)) {
|
|
3427
|
+
if (strayFolders(ctx.paths, config).some((f) => f.name === name)) return removeLeftovers(ctx, config, name, yes, listSessions2);
|
|
3428
|
+
throw unknownAccount(name, config);
|
|
3429
|
+
}
|
|
3430
|
+
const log = (line) => ctx.io.err(`${line}
|
|
3431
|
+
`);
|
|
3432
|
+
const dir = accountDir(ctx.paths, name);
|
|
3433
|
+
const st = lstatOrNull(dir);
|
|
3434
|
+
if (st?.isSymbolicLink()) {
|
|
3435
|
+
throw new XError(`not removing ${name}: ${tildify(dir, ctx.paths.home)} is a symlink, and xclaude only manages real folders there. Replace it with a real folder, or remove it by hand.`);
|
|
3436
|
+
}
|
|
3437
|
+
if (!yes) {
|
|
3438
|
+
if (!ctx.tty.stdin) throw new UsageError(`removing ${name} needs a confirmation: run it in a terminal, or pass -y`);
|
|
3439
|
+
const ok = confirm(`Remove account ${name}? Its login and settings go; shared conversations and content stay in ~/.claude.`);
|
|
3440
|
+
if (!ok) {
|
|
3441
|
+
log("xclaude: nothing removed");
|
|
3442
|
+
throw new Cancelled();
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
if (!st) {
|
|
3446
|
+
forget(ctx, name);
|
|
3447
|
+
log(`xclaude: removed ${name}; its folder was already gone, so it wasn't logged out (on macOS its Keychain login may remain)`);
|
|
3448
|
+
return EXIT_OK;
|
|
3449
|
+
}
|
|
3450
|
+
const table = shareTable(config, ctx.switches);
|
|
3451
|
+
const res = repairAccount({ paths: ctx.paths, account: name, table, lockWaitMs: Infinity, replaceWrongLinks: false, log });
|
|
3452
|
+
if (!res.clean) {
|
|
3453
|
+
for (const pr of res.problems) log(`xclaude: ${problemLine(name, pr)}`);
|
|
3454
|
+
throw new XError(`not removing ${name}: its links aren't all in place, and deleting now could delete shared data. Run \`xclaude doctor --fix\` first.`);
|
|
3455
|
+
}
|
|
3456
|
+
if (ctx.switches.normalizePaths && table.dirs.includes("plugins")) {
|
|
3457
|
+
normalizePaths(ctx.paths, { account: name, lockWaitMs: Infinity, log });
|
|
3458
|
+
}
|
|
3459
|
+
let claude = null;
|
|
3460
|
+
try {
|
|
3461
|
+
claude = findClaude(ctx.env, config, ctx.paths.home, ctx.selfPath);
|
|
3462
|
+
} catch (e) {
|
|
3463
|
+
if (!(e instanceof XError)) throw e;
|
|
3464
|
+
log(`xclaude: ${e.message}; skipping the supervisor stop and the logout`);
|
|
3465
|
+
}
|
|
3466
|
+
if (claude) {
|
|
3467
|
+
const env = identityEnv(ctx, name);
|
|
3468
|
+
await runClaude(claude, ["daemon", "stop", "--any"], env);
|
|
3469
|
+
await logout(ctx, config, claude, name, Boolean(p.values["keep-login"]), log);
|
|
3470
|
+
}
|
|
3471
|
+
const sessions = listSessions2(name);
|
|
3472
|
+
if (sessions.length) log(`xclaude: tmux sessions still running on ${name} (left alone): ${sessions.join(", ")}`);
|
|
3473
|
+
const { kept, content } = keepOnlyLinks(dir, table);
|
|
3474
|
+
removeTree(`${dir}.lock`);
|
|
3475
|
+
forget(ctx, name);
|
|
3476
|
+
log(`xclaude: removed ${name}`);
|
|
3477
|
+
const where = tildify(dir, ctx.paths.home);
|
|
3478
|
+
if (content.length) {
|
|
3479
|
+
log(
|
|
3480
|
+
`xclaude: ${where} keeps its links into ~/.claude, so ${name}'s old conversations can still open their saved long outputs, and ${name}'s own ${content.join(", ")} (not shared). Move what you want to keep; then \`xclaude rm ${name}\` deletes the rest.`
|
|
3481
|
+
);
|
|
3482
|
+
} else if (kept) {
|
|
3483
|
+
log(`xclaude: ${where} keeps only its links into ~/.claude, so ${name}'s old conversations can still open their saved long outputs; delete it with: xclaude rm ${name}`);
|
|
3484
|
+
}
|
|
3485
|
+
return EXIT_OK;
|
|
3486
|
+
}
|
|
3487
|
+
function forget(ctx, name) {
|
|
3488
|
+
const fresh = loadConfig(ctx.paths, { create: false }).config;
|
|
3489
|
+
delete fresh.accounts[name];
|
|
3490
|
+
saveConfig(ctx.paths, fresh);
|
|
3491
|
+
updateState(ctx.paths, (s) => forgetAccount(s, name));
|
|
3492
|
+
}
|
|
3493
|
+
function whyNot(f, where) {
|
|
3494
|
+
switch (f.kind) {
|
|
3495
|
+
case "content":
|
|
3496
|
+
return `not deleting ${where}: it holds more than links (${f.entries.join(", ")}). \`xclaude add ${f.name}\` takes it back as an account, merging shared content into ~/.claude; or check it and delete it by hand.`;
|
|
3497
|
+
case "stray":
|
|
3498
|
+
return `not deleting ${where}: that name couldn't be an account's, so xclaude didn't make this folder; check it by hand.`;
|
|
3499
|
+
case "link":
|
|
3500
|
+
return `not deleting ${where}: it's a symlink, and xclaude only manages real folders there.`;
|
|
3501
|
+
default:
|
|
3502
|
+
return "";
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
function removeLeftovers(ctx, config, only, yes, listSessions2) {
|
|
3506
|
+
const log = (line) => ctx.io.err(`${line}
|
|
3507
|
+
`);
|
|
3508
|
+
const where = (name) => tildify(accountDir(ctx.paths, name), ctx.paths.home);
|
|
3509
|
+
const folders = strayFolders(ctx.paths, config).filter((f) => only === null || f.name === only);
|
|
3510
|
+
if (!folders.length) {
|
|
3511
|
+
log("xclaude: no leftover folders");
|
|
3512
|
+
return EXIT_OK;
|
|
3513
|
+
}
|
|
3514
|
+
const names = [];
|
|
3515
|
+
for (const f of folders) {
|
|
3516
|
+
const sessions = f.kind === "leftover" ? listSessions2(f.name) : [];
|
|
3517
|
+
if (f.kind !== "leftover") log(`xclaude: ${whyNot(f, where(f.name))}`);
|
|
3518
|
+
else if (sessions.length) log(`xclaude: not deleting ${where(f.name)}: tmux sessions of ${f.name} still run (${sessions.join(", ")}); end them first`);
|
|
3519
|
+
else names.push(f.name);
|
|
3520
|
+
}
|
|
3521
|
+
if (!names.length) return EXIT_ERROR;
|
|
3522
|
+
if (!yes) {
|
|
3523
|
+
if (!ctx.tty.stdin) throw new UsageError("deleting leftover folders needs a confirmation: run it in a terminal, or pass -y");
|
|
3524
|
+
const which = names.length === 1 ? `${names[0]}'s leftover folder` : `the leftover folders of ${names.join(", ")}`;
|
|
3525
|
+
const ok = confirm(`Delete ${which}? Old conversations from ${names.length === 1 ? "it" : "them"} can then no longer open their saved long outputs.`);
|
|
3526
|
+
if (!ok) {
|
|
3527
|
+
log("xclaude: nothing deleted");
|
|
3528
|
+
throw new Cancelled();
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
const still = new Set(leftoverNames(ctx.paths, loadConfig(ctx.paths, { create: false }).config));
|
|
3532
|
+
let deleted = 0;
|
|
3533
|
+
for (const name of names) {
|
|
3534
|
+
if (!still.has(name)) {
|
|
3535
|
+
log(`xclaude: left ${where(name)} alone: it changed meanwhile`);
|
|
3536
|
+
continue;
|
|
3537
|
+
}
|
|
3538
|
+
const dir = accountDir(ctx.paths, name);
|
|
3539
|
+
removeTree(dir);
|
|
3540
|
+
removeTree(`${dir}.lock`);
|
|
3541
|
+
log(`xclaude: deleted ${where(name)}`);
|
|
3542
|
+
deleted++;
|
|
3543
|
+
}
|
|
3544
|
+
return deleted === folders.length ? EXIT_OK : EXIT_ERROR;
|
|
3545
|
+
}
|
|
3546
|
+
async function logout(ctx, config, claude, name, keep, log) {
|
|
3547
|
+
if (keep) {
|
|
3548
|
+
log(`xclaude: keeping ${name}'s login (--keep-login)`);
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
const others = ctx.switches.keepSameEmailLogin ? identities(config).filter((n) => n !== name).map((n) => ({ label: n, env: identityEnv(ctx, n) })) : [];
|
|
3552
|
+
if (ctx.switches.keepSameEmailLogin && !config.main.enabled) others.push({ label: "the ~/.claude login", env: identityEnv(ctx, MAIN) });
|
|
3553
|
+
const [mine, ...theirs] = await Promise.all([identityEnv(ctx, name), ...others.map((o) => o.env)].map((env) => authStatus(claude, env)));
|
|
3554
|
+
if (mine.error) {
|
|
3555
|
+
log(`xclaude: couldn't read ${name}'s login (${mine.error}), so it isn't logged out; its login may remain (on macOS, in the Keychain)`);
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
if (!mine.loggedIn) return;
|
|
3559
|
+
if (ctx.switches.keepSameEmailLogin) {
|
|
3560
|
+
const unknown = others.filter((_, i) => theirs[i].error);
|
|
3561
|
+
if (unknown.length) {
|
|
3562
|
+
log(`xclaude: not logging ${name} out: couldn't read the login of ${unknown.map((o) => o.label).join(", ")}, which may use the same email`);
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3565
|
+
const same = others.filter((_, i) => theirs[i].loggedIn && theirs[i].email === mine.email);
|
|
3566
|
+
if (same.length) {
|
|
3567
|
+
log(`xclaude: not logging ${name} out: ${same.map((o) => o.label).join(", ")} ${same.length > 1 ? "are" : "is"} logged in with the same email (${mine.email}), and a logout could affect ${same.length > 1 ? "them" : "it"}`);
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
const res = await runClaude(claude, ["auth", "logout"], identityEnv(ctx, name));
|
|
3572
|
+
if (res.code !== 0) log(`xclaude: logging ${name} out failed: ${(res.stderr || res.stdout).trim().split("\n")[0]}`);
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
// src/commands/set.ts
|
|
3576
|
+
var USAGE3 = 'usage: xclaude set <name> [--model M] [--effort E] [--args "\u2026"] [--unset model|effort|args], or xclaude set main --enable|--disable';
|
|
3577
|
+
var KEYS = ["model", "effort", "args"];
|
|
3578
|
+
function setCommand(ctx, config, args) {
|
|
3579
|
+
const p = parseOptions(args, { ...DEFAULT_OPTIONS, unset: { value: true, repeat: true }, enable: {}, disable: {} }, "set");
|
|
3580
|
+
const [name, ...extra] = p.positionals;
|
|
3581
|
+
if (!name || extra.length) throw new UsageError(USAGE3);
|
|
3582
|
+
const isMain = name === MAIN;
|
|
3583
|
+
if (!isMain && !Object.hasOwn(config.accounts, name)) throw unknownAccount(name, config);
|
|
3584
|
+
const target = isMain ? config.main : config.accounts[name];
|
|
3585
|
+
if ((p.values.enable || p.values.disable) && !isMain) {
|
|
3586
|
+
throw new UsageError("--enable and --disable are for the main identity: xclaude set main --enable");
|
|
3587
|
+
}
|
|
3588
|
+
if (p.values.enable && p.values.disable) throw new UsageError("choose --enable or --disable");
|
|
3589
|
+
const unset = p.values.unset ?? [];
|
|
3590
|
+
for (const key of unset) {
|
|
3591
|
+
if (!KEYS.includes(key)) throw new UsageError(`--unset takes model, effort or args, not "${key}"`);
|
|
3592
|
+
if (p.values[key] !== void 0) throw new UsageError(`--${key} and --unset ${key} contradict each other`);
|
|
3593
|
+
}
|
|
3594
|
+
if (Object.keys(p.values).length) {
|
|
3595
|
+
applyDefaultOptions(p, ctx.paths.home, target);
|
|
3596
|
+
for (const key of unset) {
|
|
3597
|
+
if (key === "args") target.args = [];
|
|
3598
|
+
else if (key === "model") target.model = null;
|
|
3599
|
+
else target.effort = null;
|
|
3600
|
+
}
|
|
3601
|
+
if (p.values.enable) config.main.enabled = true;
|
|
3602
|
+
if (p.values.disable) config.main.enabled = false;
|
|
3603
|
+
saveConfig(ctx.paths, config);
|
|
3604
|
+
}
|
|
3605
|
+
let head = name;
|
|
3606
|
+
if (isMain) head += config.main.enabled ? " (enabled: the login in ~/.claude)" : " (disabled; enable with: xclaude set main --enable)";
|
|
3607
|
+
ctx.io.out(`${head}
|
|
3608
|
+
${formatDefaults(target)}`);
|
|
3609
|
+
return EXIT_OK;
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
// src/commands/shell.ts
|
|
3613
|
+
function install(ctx, config, args) {
|
|
3614
|
+
const p = parseOptions(args, { bash: {}, zsh: {} }, "shell install");
|
|
3615
|
+
if (p.positionals.length) throw new UsageError("usage: xclaude shell install [--bash] [--zsh]");
|
|
3616
|
+
const targets = rcTargets(ctx.env, ctx.paths.home, process.platform, { bash: Boolean(p.values.bash), zsh: Boolean(p.values.zsh) });
|
|
3617
|
+
if (!targets.length) throw new XError("neither ~/.zshrc nor ~/.bashrc exists; pick one with --zsh or --bash (it gets created)");
|
|
3618
|
+
writeInitFiles(ctx.paths, config, VERSION);
|
|
3619
|
+
for (const t of targets) {
|
|
3620
|
+
const label = tildify(t.file, ctx.paths.home);
|
|
3621
|
+
const previous = readFileOrNull(t.file);
|
|
3622
|
+
const text = upsertBlock(previous ?? "", blockFor(initFile(ctx.paths, t.shell)), label);
|
|
3623
|
+
const res = writeRc(t.file, text, previous);
|
|
3624
|
+
if (res.status === "written") ctx.io.err(`xclaude: ${previous === null ? "created" : "updated"} ${label}
|
|
3625
|
+
`);
|
|
3626
|
+
else if (res.status === "unchanged") ctx.io.err(`xclaude: ${label} is already set up
|
|
3627
|
+
`);
|
|
3628
|
+
else {
|
|
3629
|
+
ctx.io.err(`xclaude: ${label} isn't writable (read-only, or managed elsewhere); add this block to it by hand:
|
|
3630
|
+
`);
|
|
3631
|
+
ctx.io.out(blockFor(initFile(ctx.paths, t.shell)));
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
ctx.io.err("Open a new shell (or run: exec $SHELL) to use it.\n");
|
|
3635
|
+
return EXIT_OK;
|
|
3636
|
+
}
|
|
3637
|
+
function uninstall(ctx, args) {
|
|
3638
|
+
const p = parseOptions(args, { bash: {}, zsh: {} }, "shell uninstall");
|
|
3639
|
+
if (p.positionals.length) throw new UsageError("usage: xclaude shell uninstall [--bash] [--zsh]");
|
|
3640
|
+
const only = p.values.bash || p.values.zsh ? { bash: Boolean(p.values.bash), zsh: Boolean(p.values.zsh) } : null;
|
|
3641
|
+
const targets = knownRcFiles(ctx.env, ctx.paths.home).filter((t) => !only || only[t.shell]);
|
|
3642
|
+
let count = 0;
|
|
3643
|
+
for (const t of targets) {
|
|
3644
|
+
const label = tildify(t.file, ctx.paths.home);
|
|
3645
|
+
const previous = readFileOrNull(t.file);
|
|
3646
|
+
if (previous === null) continue;
|
|
3647
|
+
const { text, found } = removeBlock(previous, label);
|
|
3648
|
+
if (!found) continue;
|
|
3649
|
+
count++;
|
|
3650
|
+
const res = writeRc(t.file, text, previous);
|
|
3651
|
+
if (res.status === "manual") ctx.io.err(`xclaude: ${label} isn't writable; remove the xclaude block from it by hand
|
|
3652
|
+
`);
|
|
3653
|
+
else ctx.io.err(`xclaude: removed the block from ${label}
|
|
3654
|
+
`);
|
|
3655
|
+
}
|
|
3656
|
+
if (!only) removeTree(ctx.paths.shell);
|
|
3657
|
+
if (!count) ctx.io.err("xclaude: no shell block was installed\n");
|
|
3658
|
+
return EXIT_OK;
|
|
3659
|
+
}
|
|
3660
|
+
function shellCommand(ctx, config, args) {
|
|
3661
|
+
const [sub, ...rest] = args;
|
|
3662
|
+
switch (sub) {
|
|
3663
|
+
case "install":
|
|
3664
|
+
return install(ctx, config, rest);
|
|
3665
|
+
case "uninstall":
|
|
3666
|
+
return uninstall(ctx, rest);
|
|
3667
|
+
case "completion": {
|
|
3668
|
+
if (rest.length !== 1 || rest[0] !== "bash" && rest[0] !== "zsh") throw new UsageError("usage: xclaude shell completion bash|zsh");
|
|
3669
|
+
ctx.io.out(rest[0] === "bash" ? initBash(VERSION, false) : initZsh(VERSION, false));
|
|
3670
|
+
return EXIT_OK;
|
|
3671
|
+
}
|
|
3672
|
+
default:
|
|
3673
|
+
throw new UsageError("usage: xclaude shell install|uninstall [--bash] [--zsh], or xclaude shell completion bash|zsh");
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
// src/commands/tmux.ts
|
|
3678
|
+
import fs26 from "node:fs";
|
|
3679
|
+
import path18 from "node:path";
|
|
3680
|
+
|
|
3681
|
+
// src/launch/args.ts
|
|
3682
|
+
function hasFlag(args, flag) {
|
|
3683
|
+
for (const a of args) {
|
|
3684
|
+
if (a === "--") return false;
|
|
3685
|
+
if (a === flag || a.startsWith(`${flag}=`)) return true;
|
|
3686
|
+
}
|
|
3687
|
+
return false;
|
|
3688
|
+
}
|
|
3689
|
+
function splitAccountArgs(args, help) {
|
|
3690
|
+
const out = [];
|
|
3691
|
+
const bare = [];
|
|
3692
|
+
for (let i = 0; i < args.length; i++) {
|
|
3693
|
+
const a = args[i];
|
|
3694
|
+
if (a === "--") {
|
|
3695
|
+
out.push(...args.slice(i));
|
|
3696
|
+
break;
|
|
3697
|
+
}
|
|
3698
|
+
const spec = a.startsWith("-") && a !== "-" && !a.includes("=") ? findOption(help, a) : void 0;
|
|
3699
|
+
if (!spec) {
|
|
3700
|
+
out.push(a);
|
|
3701
|
+
continue;
|
|
3702
|
+
}
|
|
3703
|
+
const flag = longName(spec);
|
|
3704
|
+
const next = args[i + 1];
|
|
3705
|
+
switch (spec.value) {
|
|
3706
|
+
case "none":
|
|
3707
|
+
out.push(a);
|
|
3708
|
+
break;
|
|
3709
|
+
case "required":
|
|
3710
|
+
if (next === void 0) out.push(a);
|
|
3711
|
+
else {
|
|
3712
|
+
out.push(`${flag}=${next}`);
|
|
3713
|
+
i++;
|
|
3714
|
+
}
|
|
3715
|
+
break;
|
|
3716
|
+
case "optional":
|
|
3717
|
+
if (next === void 0 || next.startsWith("-")) bare.push({ flag, names: spec.names });
|
|
3718
|
+
else {
|
|
3719
|
+
out.push(`${flag}=${next}`);
|
|
3720
|
+
i++;
|
|
3721
|
+
}
|
|
3722
|
+
break;
|
|
3723
|
+
case "variadic": {
|
|
3724
|
+
let n = 0;
|
|
3725
|
+
while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
|
3726
|
+
out.push(`${flag}=${args[++i]}`);
|
|
3727
|
+
n++;
|
|
3728
|
+
}
|
|
3729
|
+
if (!n) out.push(a);
|
|
3730
|
+
break;
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
return { args: out, bare };
|
|
3735
|
+
}
|
|
3736
|
+
function buildArgs(defaults, userArgs, help) {
|
|
3737
|
+
if (isSubcommand(userArgs[0], help)) return [...userArgs];
|
|
3738
|
+
const { args: out, bare } = defaults.args.length && help ? splitAccountArgs(defaults.args, help) : { args: [...defaults.args], bare: [] };
|
|
3739
|
+
if (defaults.model && !hasFlag(userArgs, "--model")) out.push("--model", defaults.model);
|
|
3740
|
+
if (defaults.effort && !hasFlag(userArgs, "--effort")) out.push("--effort", defaults.effort);
|
|
3741
|
+
const user = [...userArgs];
|
|
3742
|
+
const extra = bare.filter((b) => !b.names.some((n) => hasFlag(userArgs, n))).map((b) => b.flag);
|
|
3743
|
+
const dashdash = user.indexOf("--");
|
|
3744
|
+
if (dashdash >= 0) user.splice(dashdash, 0, ...extra);
|
|
3745
|
+
else user.push(...extra);
|
|
3746
|
+
return [...out, ...user];
|
|
3747
|
+
}
|
|
3748
|
+
function needsHelp(defaults, userArgs) {
|
|
3749
|
+
const first = userArgs[0];
|
|
3750
|
+
return defaults.args.length > 0 || first !== void 0 && !first.startsWith("-");
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
// src/launch/picker.ts
|
|
3754
|
+
import fs24 from "node:fs";
|
|
3755
|
+
import tty2 from "node:tty";
|
|
3756
|
+
function parseKeys(chunk) {
|
|
3757
|
+
const keys = [];
|
|
3758
|
+
let i = 0;
|
|
3759
|
+
while (i < chunk.length) {
|
|
3760
|
+
const rest = chunk.slice(i);
|
|
3761
|
+
if (rest.startsWith("\x1B[A") || rest.startsWith("\x1BOA")) {
|
|
3762
|
+
keys.push("up");
|
|
3763
|
+
i += 3;
|
|
3764
|
+
} else if (rest.startsWith("\x1B[B") || rest.startsWith("\x1BOB")) {
|
|
3765
|
+
keys.push("down");
|
|
3766
|
+
i += 3;
|
|
3767
|
+
} else if (rest.startsWith("\x1B[")) {
|
|
3768
|
+
let j = 2;
|
|
3769
|
+
while (j < rest.length && !/[@-~]/.test(rest[j])) j++;
|
|
3770
|
+
keys.push(null);
|
|
3771
|
+
i += j + 1;
|
|
3772
|
+
} else {
|
|
3773
|
+
const c = rest[0];
|
|
3774
|
+
if (c === "\x1B") keys.push(rest.length === 1 ? "cancel" : null);
|
|
3775
|
+
else if (c === "") keys.push("cancel");
|
|
3776
|
+
else if (c === "\r" || c === "\n") keys.push("enter");
|
|
3777
|
+
else if (c === "k") keys.push("up");
|
|
3778
|
+
else if (c === "j") keys.push("down");
|
|
3779
|
+
else if (c >= "1" && c <= "9") keys.push({ jump: Number(c) - 1 });
|
|
3780
|
+
else keys.push(null);
|
|
3781
|
+
i++;
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
return keys;
|
|
3785
|
+
}
|
|
3786
|
+
function step(index, count, key) {
|
|
3787
|
+
if (key === "up") return { index: (index - 1 + count) % count };
|
|
3788
|
+
if (key === "down") return { index: (index + 1) % count };
|
|
3789
|
+
if (key === "enter") return { index, done: "chosen" };
|
|
3790
|
+
if (key === "cancel") return { index, done: "cancelled" };
|
|
3791
|
+
if (key && typeof key === "object" && key.jump < count) return { index: key.jump };
|
|
3792
|
+
return { index };
|
|
3793
|
+
}
|
|
3794
|
+
var dim = (s) => `\x1B[2m${s}\x1B[22m`;
|
|
3795
|
+
var bold = (s) => `\x1B[1m${s}\x1B[22m`;
|
|
3796
|
+
function fit(text, width) {
|
|
3797
|
+
return width > 0 && text.length > width ? `${text.slice(0, Math.max(0, width - 1))}\u2026` : text;
|
|
3798
|
+
}
|
|
3799
|
+
function render(title, items, index, width = 0) {
|
|
3800
|
+
const labelWidth = Math.max(...items.map((it) => it.label.length));
|
|
3801
|
+
const lines = [bold(fit(title, width))];
|
|
3802
|
+
items.forEach((it, i) => {
|
|
3803
|
+
const marker = i === index ? "\u276F" : " ";
|
|
3804
|
+
const num = i < 9 ? String(i + 1) : " ";
|
|
3805
|
+
const label = it.label.padEnd(labelWidth);
|
|
3806
|
+
const text = fit(`${marker} ${num} ${label}${it.detail ? ` ${it.detail}` : ""}`, width);
|
|
3807
|
+
const head = text.slice(0, 5 + labelWidth);
|
|
3808
|
+
const tail = text.slice(5 + labelWidth);
|
|
3809
|
+
lines.push(i === index ? `${bold(head)}${dim(tail)}` : `${head}${dim(tail)}`);
|
|
3810
|
+
});
|
|
3811
|
+
lines.push(dim(fit("\u2191/\u2193 or j/k to move \xB7 1\u20139 to jump \xB7 Enter to choose \xB7 Esc to cancel", width)));
|
|
3812
|
+
return lines;
|
|
3813
|
+
}
|
|
3814
|
+
function preselect(names, state, cwd) {
|
|
3815
|
+
for (const candidate of [state.lastAccountByDir[cwd], state.lastUsedAccount]) {
|
|
3816
|
+
if (candidate && names.includes(candidate)) return names.indexOf(candidate);
|
|
3817
|
+
}
|
|
3818
|
+
return 0;
|
|
3819
|
+
}
|
|
3820
|
+
function openKeyboard() {
|
|
3821
|
+
let fd;
|
|
3822
|
+
try {
|
|
3823
|
+
fd = fs24.openSync("/dev/tty", "r");
|
|
3824
|
+
} catch {
|
|
3825
|
+
throw new UsageError("account required: there's no terminal to show the picker on (xclaude <account> [claude args\u2026])");
|
|
3826
|
+
}
|
|
3827
|
+
const input = new tty2.ReadStream(fd);
|
|
3828
|
+
let columns = 0;
|
|
3829
|
+
try {
|
|
3830
|
+
const size = [];
|
|
3831
|
+
const handle = input._handle;
|
|
3832
|
+
if (handle?.getWindowSize?.(size) === 0) columns = size[0] ?? 0;
|
|
3833
|
+
} catch {
|
|
3834
|
+
}
|
|
3835
|
+
return {
|
|
3836
|
+
input,
|
|
3837
|
+
columns,
|
|
3838
|
+
close: () => {
|
|
3839
|
+
input.pause();
|
|
3840
|
+
input.unref();
|
|
3841
|
+
}
|
|
3842
|
+
};
|
|
3843
|
+
}
|
|
3844
|
+
function pick(title, items, initial) {
|
|
3845
|
+
const keyboard = openKeyboard();
|
|
3846
|
+
const { input } = keyboard;
|
|
3847
|
+
const write = (s) => fs24.writeSync(2, s);
|
|
3848
|
+
let index = Math.min(Math.max(initial, 0), items.length - 1);
|
|
3849
|
+
let drawn = 0;
|
|
3850
|
+
const draw = () => {
|
|
3851
|
+
const lines = render(title, items, index, keyboard.columns);
|
|
3852
|
+
write(`${drawn ? `\r\x1B[${drawn}A` : ""}\x1B[J${lines.join("\n")}
|
|
3853
|
+
`);
|
|
3854
|
+
drawn = lines.length;
|
|
3855
|
+
};
|
|
3856
|
+
return new Promise((resolve, reject) => {
|
|
3857
|
+
const finish = (result) => {
|
|
3858
|
+
input.off("data", onData);
|
|
3859
|
+
try {
|
|
3860
|
+
input.setRawMode(false);
|
|
3861
|
+
} finally {
|
|
3862
|
+
keyboard.close();
|
|
3863
|
+
write(`\r\x1B[${drawn}A\x1B[J\x1B[?25h`);
|
|
3864
|
+
}
|
|
3865
|
+
if (result === "chosen") resolve(index);
|
|
3866
|
+
else reject(new Cancelled());
|
|
3867
|
+
};
|
|
3868
|
+
const onData = (chunk) => {
|
|
3869
|
+
for (const key of parseKeys(chunk)) {
|
|
3870
|
+
const next = step(index, items.length, key);
|
|
3871
|
+
index = next.index;
|
|
3872
|
+
if (next.done) {
|
|
3873
|
+
finish(next.done);
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
draw();
|
|
3878
|
+
};
|
|
3879
|
+
input.setRawMode(true);
|
|
3880
|
+
input.setEncoding("utf8");
|
|
3881
|
+
input.on("data", onData);
|
|
3882
|
+
input.resume();
|
|
3883
|
+
write("\x1B[?25l");
|
|
3884
|
+
draw();
|
|
3885
|
+
});
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
// src/launch/launch.ts
|
|
3889
|
+
function identityItems(config) {
|
|
3890
|
+
return identities(config).map((name) => {
|
|
3891
|
+
const d = defaultsOf(config, name);
|
|
3892
|
+
const detail = describeDefaults(d);
|
|
3893
|
+
return { label: name, detail: name === MAIN ? ["~/.claude login", detail].filter(Boolean).join(" \xB7 ") : detail };
|
|
3894
|
+
});
|
|
3895
|
+
}
|
|
3896
|
+
async function chooseAccount(ctx, config, title = "Launch Claude Code as") {
|
|
3897
|
+
const names = identities(config);
|
|
3898
|
+
if (!names.length) throw new XError("no accounts yet; add one with: xclaude add <name>");
|
|
3899
|
+
if (!ctx.tty.stdin || !ctx.tty.stderr) throw new UsageError(`account required: xclaude <account> [claude args\u2026] (accounts: ${names.join(", ")})`);
|
|
3900
|
+
const index = await pick(title, identityItems(config), preselect(names, loadState(ctx.paths), ctx.cwd));
|
|
3901
|
+
return names[index];
|
|
3902
|
+
}
|
|
3903
|
+
function prepareAccount(ctx, config, account) {
|
|
3904
|
+
const log = (line) => ctx.io.err(`${line}
|
|
3905
|
+
`);
|
|
3906
|
+
try {
|
|
3907
|
+
return repairForLaunch(ctx, config, account, log);
|
|
3908
|
+
} catch (e) {
|
|
3909
|
+
log(`xclaude: couldn't check ${account}'s links (${e.message}); launching anyway`);
|
|
3910
|
+
return [];
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
function repairForLaunch(ctx, config, account, log) {
|
|
3914
|
+
const table = shareTable(config, ctx.switches);
|
|
3915
|
+
const res = repairAccount({
|
|
3916
|
+
paths: ctx.paths,
|
|
3917
|
+
account,
|
|
3918
|
+
table,
|
|
3919
|
+
lockWaitMs: LAUNCH_LOCK_WAIT_MS,
|
|
3920
|
+
replaceWrongLinks: false,
|
|
3921
|
+
log
|
|
3922
|
+
});
|
|
3923
|
+
if (res.busy) log("xclaude: links not checked this time: another xclaude is repairing; the next launch retries");
|
|
3924
|
+
for (const p of res.problems) log(`xclaude: ${problemLine(account, p)}`);
|
|
3925
|
+
if (ctx.switches.normalizePaths && table.dirs.includes("plugins")) {
|
|
3926
|
+
normalizePaths(ctx.paths, { lockWaitMs: LAUNCH_LOCK_WAIT_MS, log });
|
|
3927
|
+
}
|
|
3928
|
+
return unknownEntries(res.names, table);
|
|
3929
|
+
}
|
|
3930
|
+
async function launch(ctx, config, account, userArgs) {
|
|
3931
|
+
const defaults = defaultsOf(config, account);
|
|
3932
|
+
if (!defaults) throw unknownAccount(account, config);
|
|
3933
|
+
const isMain = account === MAIN;
|
|
3934
|
+
const unknown = isMain ? [] : prepareAccount(ctx, config, account);
|
|
3935
|
+
const claude = findClaude(ctx.env, config, ctx.paths.home, ctx.selfPath);
|
|
3936
|
+
const help = needsHelp(defaults, userArgs) ? loadHelp(claude, ctx.paths.cache, ctx.env) : null;
|
|
3937
|
+
const args = buildArgs(defaults, userArgs, help);
|
|
3938
|
+
const env = buildEnv(ctx.env, { name: account, configDir: isMain ? null : accountDir(ctx.paths, account) });
|
|
3939
|
+
try {
|
|
3940
|
+
updateState(ctx.paths, (state) => {
|
|
3941
|
+
recordUse(state, ctx.cwd, account);
|
|
3942
|
+
const configLabel = tildify(ctx.paths.config, ctx.paths.home);
|
|
3943
|
+
for (const entry of takeNewUnknown(state, account, unknown)) ctx.io.err(`${unknownNotice(account, entry, configLabel)}
|
|
3944
|
+
`);
|
|
3945
|
+
});
|
|
3946
|
+
} catch {
|
|
3947
|
+
}
|
|
3948
|
+
return ctx.exec(claude, [claude, ...args], env);
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
// src/tmux/claude-state.ts
|
|
3952
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
3953
|
+
import fs25 from "node:fs";
|
|
3954
|
+
function describeAgent(e) {
|
|
3955
|
+
if (e.status === "waiting") return { text: e.waitingFor ? `needs input (${e.waitingFor})` : "needs input", rank: 3 };
|
|
3956
|
+
if (e.status === "busy") return { text: "working", rank: 2 };
|
|
3957
|
+
if (e.status === "idle") return { text: "idle", rank: 1 };
|
|
3958
|
+
return null;
|
|
3959
|
+
}
|
|
3960
|
+
function parentLookup() {
|
|
3961
|
+
if (process.platform === "linux" && fs25.existsSync("/proc/self/stat")) {
|
|
3962
|
+
return (pid) => {
|
|
3963
|
+
try {
|
|
3964
|
+
const stat = fs25.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
3965
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
3966
|
+
return Number.isFinite(ppid) ? ppid : null;
|
|
3967
|
+
} catch {
|
|
3968
|
+
return null;
|
|
3969
|
+
}
|
|
3970
|
+
};
|
|
3971
|
+
}
|
|
3972
|
+
const map = /* @__PURE__ */ new Map();
|
|
3973
|
+
const res = spawnSync4("ps", ["-axo", "pid=,ppid="], { encoding: "utf8", timeout: 1e4 });
|
|
3974
|
+
for (const line of (res.stdout ?? "").split("\n")) {
|
|
3975
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number);
|
|
3976
|
+
if (pid && ppid !== void 0 && Number.isFinite(ppid)) map.set(pid, ppid);
|
|
3977
|
+
}
|
|
3978
|
+
return (pid) => map.get(pid) ?? null;
|
|
3979
|
+
}
|
|
3980
|
+
function sessionOfPid(pid, panes, parentOf) {
|
|
3981
|
+
let current = pid;
|
|
3982
|
+
for (let i = 0; current !== null && current > 1 && i < 64; i++) {
|
|
3983
|
+
const session = panes.get(current);
|
|
3984
|
+
if (session) return session;
|
|
3985
|
+
current = parentOf(current);
|
|
3986
|
+
}
|
|
3987
|
+
return null;
|
|
3988
|
+
}
|
|
3989
|
+
function statesBySession(entries, panes, parentOf) {
|
|
3990
|
+
const best = /* @__PURE__ */ new Map();
|
|
3991
|
+
for (const e of entries) {
|
|
3992
|
+
if (typeof e.pid !== "number") continue;
|
|
3993
|
+
const state = describeAgent(e);
|
|
3994
|
+
if (!state) continue;
|
|
3995
|
+
const session = sessionOfPid(e.pid, panes, parentOf);
|
|
3996
|
+
if (!session) continue;
|
|
3997
|
+
const prev = best.get(session);
|
|
3998
|
+
if (!prev || state.rank > prev.rank) best.set(session, state);
|
|
3999
|
+
}
|
|
4000
|
+
return new Map([...best].map(([k, v]) => [k, v.text]));
|
|
4001
|
+
}
|
|
4002
|
+
function panePids(tmux) {
|
|
4003
|
+
const panes = /* @__PURE__ */ new Map();
|
|
4004
|
+
const res = tmux.run(["list-panes", "-a", "-F", "#{session_id} #{pane_pid}"]);
|
|
4005
|
+
for (const line of res.stdout.split("\n")) {
|
|
4006
|
+
const [session, pid] = line.trim().split(" ");
|
|
4007
|
+
if (session && pid) panes.set(Number(pid), session);
|
|
4008
|
+
}
|
|
4009
|
+
return panes;
|
|
4010
|
+
}
|
|
4011
|
+
async function agents(claude, env) {
|
|
4012
|
+
const res = await runClaude(claude, ["agents", "--json"], env, 15e3);
|
|
4013
|
+
try {
|
|
4014
|
+
const parsed = JSON.parse(res.stdout);
|
|
4015
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4016
|
+
} catch {
|
|
4017
|
+
return [];
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
async function claudeStates(ctx, config, tmux, sessions) {
|
|
4021
|
+
const names = identities(config);
|
|
4022
|
+
if (!sessions.length || !names.length) return /* @__PURE__ */ new Map();
|
|
4023
|
+
let claude;
|
|
4024
|
+
try {
|
|
4025
|
+
claude = findClaude(ctx.env, config, ctx.paths.home, ctx.selfPath);
|
|
4026
|
+
} catch (e) {
|
|
4027
|
+
if (e instanceof XError) return /* @__PURE__ */ new Map();
|
|
4028
|
+
throw e;
|
|
4029
|
+
}
|
|
4030
|
+
const first = sessions.find((s) => s.account && names.includes(s.account))?.account ?? names[0];
|
|
4031
|
+
const who = ctx.switches.agentsPerAccount ? names : [first];
|
|
4032
|
+
const lists = await Promise.all(who.map((n) => agents(claude, identityEnv(ctx, n))));
|
|
4033
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4034
|
+
const entries = lists.flat().filter((e) => typeof e.pid !== "number" || !seen.has(e.pid) && seen.add(e.pid));
|
|
4035
|
+
return statesBySession(entries, panePids(tmux), parentLookup());
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
// src/commands/tmux.ts
|
|
4039
|
+
var NEW_USAGE = "usage: xclaude tmux new <label> [--dir <path>] [--detach] [--empty] [<account> [claude args\u2026]]";
|
|
4040
|
+
function parseNew(args) {
|
|
4041
|
+
const [label, ...rest] = args;
|
|
4042
|
+
if (!label || label.startsWith("-")) throw new UsageError(NEW_USAGE);
|
|
4043
|
+
if (!LABEL.test(label)) {
|
|
4044
|
+
throw new UsageError(`"${label}" isn't a valid label: letters, digits, _ and -, starting with a letter or digit, at most 64 characters`);
|
|
4045
|
+
}
|
|
4046
|
+
const req = { label, dir: null, detach: false, empty: false, account: null, claudeArgs: [] };
|
|
4047
|
+
let i = 0;
|
|
4048
|
+
for (; i < rest.length && rest[i].startsWith("--"); i++) {
|
|
4049
|
+
const a = rest[i];
|
|
4050
|
+
if (a === "--detach") req.detach = true;
|
|
4051
|
+
else if (a === "--empty") req.empty = true;
|
|
4052
|
+
else if (a === "--dir") {
|
|
4053
|
+
const v = rest[++i];
|
|
4054
|
+
if (v === void 0) throw new UsageError("tmux new: --dir needs a path");
|
|
4055
|
+
req.dir = v;
|
|
4056
|
+
} else if (a.startsWith("--dir=")) req.dir = a.slice("--dir=".length);
|
|
4057
|
+
else throw new UsageError(`tmux new: unknown option ${a} (xclaude's options come right after the label, Claude Code's after the account)`);
|
|
4058
|
+
}
|
|
4059
|
+
req.account = rest[i] ?? null;
|
|
4060
|
+
req.claudeArgs = rest.slice(i + 1);
|
|
4061
|
+
if (req.empty && req.account) throw new UsageError("tmux new: --empty and an account don't go together");
|
|
4062
|
+
return req;
|
|
4063
|
+
}
|
|
4064
|
+
function describeSession(s, home) {
|
|
4065
|
+
return `${s.account ?? "empty"}, ${tildify(s.dir, home)}`;
|
|
4066
|
+
}
|
|
4067
|
+
async function attachTo(ctx, tmux, id) {
|
|
4068
|
+
if (ctx.env.TMUX) {
|
|
4069
|
+
tmux.must(["switch-client", "-t", id]);
|
|
4070
|
+
return EXIT_OK;
|
|
4071
|
+
}
|
|
4072
|
+
return ctx.exec(tmux.bin, [tmux.bin, "attach-session", "-t", id], tmux.env);
|
|
4073
|
+
}
|
|
4074
|
+
function showAccount(tmux, id, account) {
|
|
4075
|
+
const prefix = `[${account}] `;
|
|
4076
|
+
const right = tmux.must(["show-options", "-gv", "status-right"]).replace(/\n$/, "");
|
|
4077
|
+
const length = Number(tmux.must(["show-options", "-gv", "status-right-length"]).trim()) || 40;
|
|
4078
|
+
tmux.must(["set-option", "-t", id, "status-right", `${prefix}${right}`]);
|
|
4079
|
+
tmux.must(["set-option", "-t", id, "status-right-length", String(length + prefix.length)]);
|
|
4080
|
+
}
|
|
4081
|
+
async function tmuxNew(ctx, config, args) {
|
|
4082
|
+
const req = parseNew(args);
|
|
4083
|
+
const tmux = new Tmux(ctx.env);
|
|
4084
|
+
tmux.requireVersion();
|
|
4085
|
+
let account = req.account;
|
|
4086
|
+
if (!req.empty) {
|
|
4087
|
+
account ??= await chooseAccount(ctx, config, `Run Claude Code in tmux session "${req.label}" as`);
|
|
4088
|
+
if (!defaultsOf(config, account)) throw unknownAccount(account, config);
|
|
4089
|
+
}
|
|
4090
|
+
const dir = path18.resolve(ctx.cwd, req.dir ?? ".");
|
|
4091
|
+
if (!fs26.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new XError(`--dir ${req.dir}: not a directory`);
|
|
4092
|
+
const existing = findByLabel(listSessions(tmux), req.label);
|
|
4093
|
+
if (existing) {
|
|
4094
|
+
throw new XError(`"${req.label}" is running (${describeSession(existing, ctx.paths.home)}): xclaude tmux attach ${req.label}, or kill it first`);
|
|
4095
|
+
}
|
|
4096
|
+
const name = sessionName(account, req.label);
|
|
4097
|
+
if (tmux.hasSession(name)) throw new XError(`a tmux session named "${name}" exists but isn't xclaude's; rename or end it first`);
|
|
4098
|
+
const id = tmux.must(["new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "-c", dir]).trim();
|
|
4099
|
+
const options = [
|
|
4100
|
+
["@xclaude", "1"],
|
|
4101
|
+
["@xclaude_label", req.label],
|
|
4102
|
+
["@xclaude_account", account ?? ""],
|
|
4103
|
+
["@xclaude_dir", dir]
|
|
4104
|
+
];
|
|
4105
|
+
for (const [key, value] of options) tmux.must(["set-option", "-t", id, key, value]);
|
|
4106
|
+
if (account && config.tmux.statusRight) showAccount(tmux, id, account);
|
|
4107
|
+
if (account) {
|
|
4108
|
+
tmux.must(["send-keys", "-t", id, "-l", "--", quoteShellWords(["xclaude", account, ...req.claudeArgs])]);
|
|
4109
|
+
tmux.must(["send-keys", "-t", id, "Enter"]);
|
|
4110
|
+
}
|
|
4111
|
+
if (req.detach) {
|
|
4112
|
+
ctx.io.err(`xclaude: started tmux session "${req.label}"; attach with: xclaude tmux attach ${req.label}
|
|
4113
|
+
`);
|
|
4114
|
+
return EXIT_OK;
|
|
4115
|
+
}
|
|
4116
|
+
return attachTo(ctx, tmux, id);
|
|
4117
|
+
}
|
|
4118
|
+
function noSessions() {
|
|
4119
|
+
return new XError("no xclaude tmux sessions; start one with: xclaude tmux new <label> [<account>]");
|
|
4120
|
+
}
|
|
4121
|
+
function requireSession(sessions, label) {
|
|
4122
|
+
const s = findByLabel(sessions, label);
|
|
4123
|
+
if (s) return s;
|
|
4124
|
+
const labels = sessions.map((x) => x.label);
|
|
4125
|
+
throw new XError(`no xclaude tmux session "${label}"${labels.length ? ` (sessions: ${labels.join(", ")})` : ""}`);
|
|
4126
|
+
}
|
|
4127
|
+
async function tmuxAttach(ctx, args) {
|
|
4128
|
+
if (args.length > 1) throw new UsageError("usage: xclaude tmux attach [<label>]");
|
|
4129
|
+
const tmux = new Tmux(ctx.env);
|
|
4130
|
+
const sessions = listSessions(tmux);
|
|
4131
|
+
const [label] = args;
|
|
4132
|
+
if (label !== void 0) return attachTo(ctx, tmux, requireSession(sessions, label).id);
|
|
4133
|
+
if (!sessions.length) throw noSessions();
|
|
4134
|
+
if (!ctx.tty.stdin || !ctx.tty.stderr) throw new UsageError(`label required: xclaude tmux attach <label> (sessions: ${sessions.map((s) => s.label).join(", ")})`);
|
|
4135
|
+
const items = sessions.map((s) => ({
|
|
4136
|
+
label: s.label,
|
|
4137
|
+
detail: [s.account ?? "empty", tildify(s.dir, ctx.paths.home), s.attached ? "attached" : ""].filter(Boolean).join(" \xB7 ")
|
|
4138
|
+
}));
|
|
4139
|
+
const index = await pick("Attach to tmux session", items, 0);
|
|
4140
|
+
return attachTo(ctx, tmux, sessions[index].id);
|
|
4141
|
+
}
|
|
4142
|
+
function formatCreated(unixSeconds, now = /* @__PURE__ */ new Date()) {
|
|
4143
|
+
const d = new Date(unixSeconds * 1e3);
|
|
4144
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
4145
|
+
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
4146
|
+
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
|
4147
|
+
return sameDay ? time : `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`;
|
|
4148
|
+
}
|
|
4149
|
+
async function tmuxLs(ctx, config, args) {
|
|
4150
|
+
if (args.length) throw new UsageError("usage: xclaude tmux ls");
|
|
4151
|
+
const tmux = new Tmux(ctx.env);
|
|
4152
|
+
const sessions = listSessions(tmux);
|
|
4153
|
+
if (!sessions.length) {
|
|
4154
|
+
ctx.io.out("No xclaude tmux sessions. Start one with: xclaude tmux new <label> [<account>]\n");
|
|
4155
|
+
return EXIT_OK;
|
|
4156
|
+
}
|
|
4157
|
+
const claude = await claudeStates(ctx, config, tmux, sessions);
|
|
4158
|
+
const rows = sessions.map((s) => [
|
|
4159
|
+
s.label,
|
|
4160
|
+
s.account ?? DASH,
|
|
4161
|
+
tildify(s.dir, ctx.paths.home),
|
|
4162
|
+
s.attached ? "yes" : "no",
|
|
4163
|
+
formatCreated(s.created),
|
|
4164
|
+
claude.get(s.id) ?? DASH
|
|
4165
|
+
]);
|
|
4166
|
+
ctx.io.out(formatTable(["LABEL", "ACCOUNT", "WORKDIR", "ATTACHED", "CREATED", "CLAUDE"], rows));
|
|
4167
|
+
return EXIT_OK;
|
|
4168
|
+
}
|
|
4169
|
+
function tmuxKill(ctx, args) {
|
|
4170
|
+
if (args.length !== 1) throw new UsageError("usage: xclaude tmux kill <label>");
|
|
4171
|
+
const tmux = new Tmux(ctx.env);
|
|
4172
|
+
const s = requireSession(listSessions(tmux), args[0]);
|
|
4173
|
+
tmux.must(["kill-session", "-t", s.id]);
|
|
4174
|
+
ctx.io.err(`xclaude: ended tmux session "${s.label}"
|
|
4175
|
+
`);
|
|
4176
|
+
return EXIT_OK;
|
|
4177
|
+
}
|
|
4178
|
+
async function tmuxCommand(ctx, config, args) {
|
|
4179
|
+
const [sub = "ls", ...rest] = args;
|
|
4180
|
+
switch (sub) {
|
|
4181
|
+
case "new":
|
|
4182
|
+
return tmuxNew(ctx, config, rest);
|
|
4183
|
+
case "attach":
|
|
4184
|
+
return tmuxAttach(ctx, rest);
|
|
4185
|
+
case "ls":
|
|
4186
|
+
return tmuxLs(ctx, config, rest);
|
|
4187
|
+
case "kill":
|
|
4188
|
+
return tmuxKill(ctx, rest);
|
|
4189
|
+
default:
|
|
4190
|
+
throw new UsageError(`tmux: unknown subcommand "${sub}" (new, attach, ls, kill)`);
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
function accountSessionLabels(ctx, account) {
|
|
4194
|
+
try {
|
|
4195
|
+
return listSessions(new Tmux(ctx.env)).filter((s) => s.account === account).map((s) => s.label);
|
|
4196
|
+
} catch {
|
|
4197
|
+
return [];
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
// src/main.ts
|
|
4202
|
+
async function main(argv, ctx) {
|
|
4203
|
+
const [first] = argv;
|
|
4204
|
+
if (first === "-v" || first === "--version") {
|
|
4205
|
+
ctx.io.out(`${VERSION}
|
|
4206
|
+
`);
|
|
4207
|
+
return EXIT_OK;
|
|
4208
|
+
}
|
|
4209
|
+
if (first === "-h" || first === "--help") {
|
|
4210
|
+
ctx.io.out(helpText());
|
|
4211
|
+
return EXIT_OK;
|
|
4212
|
+
}
|
|
4213
|
+
if (first === "help") {
|
|
4214
|
+
ctx.io.out(helpText(wantsHelp(argv.slice(1)) ? "help" : argv[1]));
|
|
4215
|
+
return EXIT_OK;
|
|
4216
|
+
}
|
|
4217
|
+
const tmuxSub = first === "tmux" && ["new", "attach", "ls", "kill"].includes(argv[1] ?? "");
|
|
4218
|
+
if (first !== void 0 && COMMANDS.includes(first) && (wantsHelp(argv.slice(1)) || tmuxSub && wantsHelp(argv.slice(2)))) {
|
|
4219
|
+
ctx.io.out(helpText(first));
|
|
4220
|
+
return EXIT_OK;
|
|
4221
|
+
}
|
|
4222
|
+
if (first === "__complete") return completeCommand(ctx, argv.slice(1));
|
|
4223
|
+
if (first === "__names") return namesCommand(ctx);
|
|
4224
|
+
if (first === "doctor") return doctorCommand(ctx, argv.slice(1));
|
|
4225
|
+
const { config, created } = loadConfig(ctx.paths, { create: true });
|
|
4226
|
+
if (created && first !== "add" && first !== "shell") {
|
|
4227
|
+
const where = tildify(ctx.paths.xhome, ctx.paths.home);
|
|
4228
|
+
ctx.io.err(
|
|
4229
|
+
`xclaude: created ${where}. Next: \`xclaude add <name>\` to add an account, then \`xclaude shell install\` for completion.
|
|
4230
|
+
`
|
|
4231
|
+
);
|
|
4232
|
+
}
|
|
4233
|
+
try {
|
|
4234
|
+
if (loadState(ctx.paths).installedVersion !== VERSION) {
|
|
4235
|
+
refreshInitFiles(ctx.paths, config, VERSION);
|
|
4236
|
+
updateState(ctx.paths, (s) => {
|
|
4237
|
+
s.installedVersion = VERSION;
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
} catch {
|
|
4241
|
+
}
|
|
4242
|
+
const action = classify(argv, config);
|
|
4243
|
+
switch (action.kind) {
|
|
4244
|
+
case "version":
|
|
4245
|
+
case "help":
|
|
4246
|
+
return EXIT_OK;
|
|
4247
|
+
// handled above
|
|
4248
|
+
case "command":
|
|
4249
|
+
switch (action.name) {
|
|
4250
|
+
case "add":
|
|
4251
|
+
return addCommand(ctx, config, action.args);
|
|
4252
|
+
case "set":
|
|
4253
|
+
return setCommand(ctx, config, action.args);
|
|
4254
|
+
case "ls":
|
|
4255
|
+
return lsCommand(ctx, config, action.args);
|
|
4256
|
+
case "rm":
|
|
4257
|
+
return rmCommand(ctx, config, action.args, (account) => accountSessionLabels(ctx, account));
|
|
4258
|
+
case "tmux":
|
|
4259
|
+
return tmuxCommand(ctx, config, action.args);
|
|
4260
|
+
case "shell":
|
|
4261
|
+
return shellCommand(ctx, config, action.args);
|
|
4262
|
+
case "guard":
|
|
4263
|
+
return guardCommand(ctx, config, action.args);
|
|
4264
|
+
default:
|
|
4265
|
+
throw new XError(`${action.name}: unexpected here`);
|
|
4266
|
+
}
|
|
4267
|
+
case "hidden":
|
|
4268
|
+
return EXIT_OK;
|
|
4269
|
+
// handled above
|
|
4270
|
+
case "launch":
|
|
4271
|
+
return launch(ctx, config, action.account ?? await chooseAccount(ctx, config), action.args);
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
// src/cli.ts
|
|
4276
|
+
main(process.argv.slice(2), processCtx(stdio)).then(
|
|
4277
|
+
(code) => {
|
|
4278
|
+
process.exitCode = code;
|
|
4279
|
+
},
|
|
4280
|
+
(e) => {
|
|
4281
|
+
if (e instanceof Cancelled) {
|
|
4282
|
+
process.exitCode = e.exitCode;
|
|
4283
|
+
} else if (e instanceof XError) {
|
|
4284
|
+
stdio.err(`xclaude: ${e.message}
|
|
4285
|
+
`);
|
|
4286
|
+
process.exitCode = e.exitCode;
|
|
4287
|
+
} else {
|
|
4288
|
+
stdio.err(`xclaude: unexpected error: ${e instanceof Error ? e.stack ?? e.message : String(e)}
|
|
4289
|
+
`);
|
|
4290
|
+
process.exitCode = EXIT_ERROR;
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
);
|