claude-multi 0.4.2 → 0.4.4
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/CHANGELOG.md +27 -0
- package/dist/cli.js +173 -49
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.3] - 2026-01-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Broken Symlink Detection**: Automatically detect and fix broken symlinks
|
|
12
|
+
- New `isBrokenSymlink()` and `detectBrokenSymlinks()` functions
|
|
13
|
+
- Auto-fix broken symlinks when creating or updating instances
|
|
14
|
+
- Handles edge cases: broken symlinks, existing directories, incorrect symlink targets
|
|
15
|
+
- **`fix-symlinks` Command**: Fix broken symlinks for existing instances
|
|
16
|
+
- Interactive instance selection with multi-select support
|
|
17
|
+
- `--all` flag to fix all instances at once
|
|
18
|
+
- Auto-fixes instances with auto-sync enabled, warns for manual mode
|
|
19
|
+
- Clear status indicators for broken symlinks vs. valid symlinks
|
|
20
|
+
- **Interactive Re-sync**: Added "Re-sync symlinks" option to interactive mode menu
|
|
21
|
+
- Quick access to symlink diagnosis and repair from interactive UI
|
|
22
|
+
- **Test Suite**: Comprehensive tests for symlink detection and fixing
|
|
23
|
+
- Tests for broken symlink detection
|
|
24
|
+
- Tests for valid symlink handling
|
|
25
|
+
- Tests for mixed scenarios (broken + valid symlinks)
|
|
26
|
+
- Error handling tests for non-existent paths
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Symlink creation now uses `lstatSync` for proper broken symlink detection
|
|
30
|
+
- Previously, `existsSync()` returned `false` for broken symlinks, preventing detection
|
|
31
|
+
- Now correctly identifies and repairs broken symlinks in all scenarios
|
|
32
|
+
|
|
33
|
+
[0.4.3]: https://github.com/hmziqrs/claude-multi/compare/v0.4.2...v0.4.3
|
|
34
|
+
|
|
8
35
|
## [0.4.2] - 2026-01-26
|
|
9
36
|
|
|
10
37
|
### Fixed
|
package/dist/cli.js
CHANGED
|
@@ -7286,10 +7286,10 @@ import { homedir as homedir2 } from "os";
|
|
|
7286
7286
|
import { join as join3 } from "path";
|
|
7287
7287
|
|
|
7288
7288
|
// src/config.ts
|
|
7289
|
-
import { existsSync, mkdirSync, readdirSync, statSync,
|
|
7289
|
+
import { existsSync, mkdirSync, readdirSync, statSync, readlinkSync, rmSync, lstatSync } from "fs";
|
|
7290
7290
|
import { readFile, writeFile, copyFile, mkdir, symlink } from "fs/promises";
|
|
7291
7291
|
import { homedir } from "os";
|
|
7292
|
-
import { join } from "path";
|
|
7292
|
+
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
7293
7293
|
|
|
7294
7294
|
// src/templates.ts
|
|
7295
7295
|
var PROVIDER_TEMPLATES = {
|
|
@@ -7353,6 +7353,56 @@ function applyProviderTemplate(template, apiKey) {
|
|
|
7353
7353
|
// src/config.ts
|
|
7354
7354
|
var CONFIG_DIR = join(homedir(), ".claude-multi");
|
|
7355
7355
|
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7356
|
+
var SYNC_DIRS = ["plugins", "skills"];
|
|
7357
|
+
function lstatSafe(path) {
|
|
7358
|
+
try {
|
|
7359
|
+
return lstatSync(path);
|
|
7360
|
+
} catch {
|
|
7361
|
+
return null;
|
|
7362
|
+
}
|
|
7363
|
+
}
|
|
7364
|
+
function resolveSymlinkTarget(symlinkPath) {
|
|
7365
|
+
try {
|
|
7366
|
+
const link = readlinkSync(symlinkPath);
|
|
7367
|
+
if (isAbsolute(link))
|
|
7368
|
+
return link;
|
|
7369
|
+
return resolve(dirname(symlinkPath), link);
|
|
7370
|
+
} catch {
|
|
7371
|
+
return null;
|
|
7372
|
+
}
|
|
7373
|
+
}
|
|
7374
|
+
async function ensureDirSymlink(params) {
|
|
7375
|
+
const { configDir, sourceBaseDir, dir, logLabel } = params;
|
|
7376
|
+
const targetPath = join(configDir, dir);
|
|
7377
|
+
const sourcePath = join(sourceBaseDir, dir);
|
|
7378
|
+
if (!existsSync(sourcePath)) {
|
|
7379
|
+
if (logLabel) {
|
|
7380
|
+
console.log(source_default.yellow(` \u26A0 Source ${dir} not found in ${logLabel}, skipping`));
|
|
7381
|
+
}
|
|
7382
|
+
return "skipped";
|
|
7383
|
+
}
|
|
7384
|
+
const existing = lstatSafe(targetPath);
|
|
7385
|
+
if (existing) {
|
|
7386
|
+
if (existing.isSymbolicLink()) {
|
|
7387
|
+
const resolvedExisting = resolveSymlinkTarget(targetPath);
|
|
7388
|
+
if (resolvedExisting === sourcePath) {
|
|
7389
|
+
if (logLabel)
|
|
7390
|
+
console.log(source_default.gray(` \u2713 ${dir} already synced`));
|
|
7391
|
+
return "already";
|
|
7392
|
+
}
|
|
7393
|
+
rmSync(targetPath, { force: true });
|
|
7394
|
+
} else {
|
|
7395
|
+
rmSync(targetPath, { force: true, recursive: true });
|
|
7396
|
+
}
|
|
7397
|
+
}
|
|
7398
|
+
const linkTarget = relative(configDir, sourcePath);
|
|
7399
|
+
const type = process.platform === "win32" ? "junction" : "dir";
|
|
7400
|
+
await symlink(linkTarget, targetPath, type);
|
|
7401
|
+
if (logLabel) {
|
|
7402
|
+
console.log(source_default.green(` \u2713 Symlinked: ${dir} -> ${logLabel}/${dir}`));
|
|
7403
|
+
}
|
|
7404
|
+
return "linked";
|
|
7405
|
+
}
|
|
7356
7406
|
function ensureConfigDir() {
|
|
7357
7407
|
if (!existsSync(CONFIG_DIR)) {
|
|
7358
7408
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -7420,6 +7470,39 @@ function getDefaultClaudeDir() {
|
|
|
7420
7470
|
}
|
|
7421
7471
|
return join(homedir(), ".claude");
|
|
7422
7472
|
}
|
|
7473
|
+
function isBrokenSymlink(path) {
|
|
7474
|
+
try {
|
|
7475
|
+
const stats = lstatSync(path);
|
|
7476
|
+
if (!stats.isSymbolicLink())
|
|
7477
|
+
return false;
|
|
7478
|
+
const target = readlinkSync(path);
|
|
7479
|
+
if (!target)
|
|
7480
|
+
return true;
|
|
7481
|
+
let targetPath;
|
|
7482
|
+
if (isAbsolute(target)) {
|
|
7483
|
+
targetPath = target;
|
|
7484
|
+
} else {
|
|
7485
|
+
targetPath = resolve(dirname(path), target);
|
|
7486
|
+
}
|
|
7487
|
+
return !existsSync(targetPath);
|
|
7488
|
+
} catch {
|
|
7489
|
+
return false;
|
|
7490
|
+
}
|
|
7491
|
+
}
|
|
7492
|
+
function detectBrokenSymlinks(configDir) {
|
|
7493
|
+
const result = { broken: [], all: [] };
|
|
7494
|
+
for (const dir of SYNC_DIRS) {
|
|
7495
|
+
const targetPath = join(configDir, dir);
|
|
7496
|
+
try {
|
|
7497
|
+
const stats = lstatSync(targetPath);
|
|
7498
|
+
result.all.push(dir);
|
|
7499
|
+
if (stats.isSymbolicLink() && isBrokenSymlink(targetPath)) {
|
|
7500
|
+
result.broken.push(dir);
|
|
7501
|
+
}
|
|
7502
|
+
} catch {}
|
|
7503
|
+
}
|
|
7504
|
+
return result;
|
|
7505
|
+
}
|
|
7423
7506
|
function hasDefaultClaudeConfig() {
|
|
7424
7507
|
const defaultDir = getDefaultClaudeDir();
|
|
7425
7508
|
const settingsFile = join(defaultDir, "settings.json");
|
|
@@ -7458,7 +7541,6 @@ async function copyAllFromDefault(targetConfigDir, autoSync = true) {
|
|
|
7458
7541
|
if (!existsSync(targetConfigDir)) {
|
|
7459
7542
|
await mkdir(targetConfigDir, { recursive: true });
|
|
7460
7543
|
}
|
|
7461
|
-
const symlinkDirs = ["plugins", "skills"];
|
|
7462
7544
|
const excludeFiles = [
|
|
7463
7545
|
"config.json",
|
|
7464
7546
|
".config.json",
|
|
@@ -7483,18 +7565,12 @@ async function copyAllFromDefault(targetConfigDir, autoSync = true) {
|
|
|
7483
7565
|
continue;
|
|
7484
7566
|
}
|
|
7485
7567
|
if (stat.isDirectory()) {
|
|
7486
|
-
if (autoSync &&
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
7492
|
-
rmSync(targetPath, { force: true, recursive: true });
|
|
7493
|
-
}
|
|
7494
|
-
}
|
|
7495
|
-
const absoluteSource = join(homedir(), ".claude", entry);
|
|
7496
|
-
await symlink(absoluteSource, targetPath, "dir");
|
|
7497
|
-
console.log(` Symlinked: ${entry} -> ~/.claude/${entry}`);
|
|
7568
|
+
if (autoSync && SYNC_DIRS.includes(entry)) {
|
|
7569
|
+
await ensureDirSymlink({
|
|
7570
|
+
configDir: target,
|
|
7571
|
+
sourceBaseDir: defaultDir,
|
|
7572
|
+
dir: entry
|
|
7573
|
+
});
|
|
7498
7574
|
} else {
|
|
7499
7575
|
if (!existsSync(targetPath)) {
|
|
7500
7576
|
await mkdir(targetPath, { recursive: true });
|
|
@@ -7611,46 +7687,28 @@ async function createSettingsFromTemplate(targetConfigDir, template, apiKey) {
|
|
|
7611
7687
|
}
|
|
7612
7688
|
async function syncPluginsAndSkills(configDir) {
|
|
7613
7689
|
const defaultDir = getDefaultClaudeDir();
|
|
7614
|
-
const symlinkDirs = ["plugins", "skills"];
|
|
7615
7690
|
if (!existsSync(configDir)) {
|
|
7616
7691
|
throw new Error("Instance config directory does not exist");
|
|
7617
7692
|
}
|
|
7618
|
-
for (const dir of
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
}
|
|
7625
|
-
if (existsSync(targetPath)) {
|
|
7626
|
-
try {
|
|
7627
|
-
const linkTarget = readlinkSync(targetPath);
|
|
7628
|
-
const expectedTarget = join("..", "..", ".claude", dir);
|
|
7629
|
-
if (linkTarget === expectedTarget || linkTarget === sourcePath) {
|
|
7630
|
-
console.log(source_default.gray(` \u2713 ${dir} already synced`));
|
|
7631
|
-
continue;
|
|
7632
|
-
}
|
|
7633
|
-
rmSync(targetPath, { force: true });
|
|
7634
|
-
} catch {
|
|
7635
|
-
rmSync(targetPath, { force: true, recursive: true });
|
|
7636
|
-
}
|
|
7637
|
-
}
|
|
7638
|
-
const relativePath = join("..", "..", ".claude", dir);
|
|
7639
|
-
await symlink(relativePath, targetPath, "dir");
|
|
7640
|
-
console.log(source_default.green(` \u2713 Symlinked: ${dir} -> ~/.claude/${dir}`));
|
|
7693
|
+
for (const dir of SYNC_DIRS) {
|
|
7694
|
+
await ensureDirSymlink({
|
|
7695
|
+
configDir,
|
|
7696
|
+
sourceBaseDir: defaultDir,
|
|
7697
|
+
dir,
|
|
7698
|
+
logLabel: defaultDir
|
|
7699
|
+
});
|
|
7641
7700
|
}
|
|
7642
7701
|
}
|
|
7643
7702
|
async function unsyncPluginsAndSkills(configDir) {
|
|
7644
7703
|
const defaultDir = getDefaultClaudeDir();
|
|
7645
|
-
const symlinkDirs = ["plugins", "skills"];
|
|
7646
7704
|
if (!existsSync(configDir)) {
|
|
7647
7705
|
throw new Error("Instance config directory does not exist");
|
|
7648
7706
|
}
|
|
7649
|
-
for (const dir of
|
|
7707
|
+
for (const dir of SYNC_DIRS) {
|
|
7650
7708
|
const targetPath = join(configDir, dir);
|
|
7651
7709
|
const sourcePath = join(defaultDir, dir);
|
|
7652
7710
|
if (!existsSync(sourcePath)) {
|
|
7653
|
-
console.log(source_default.yellow(` \u26A0 Source ${dir} not found in
|
|
7711
|
+
console.log(source_default.yellow(` \u26A0 Source ${dir} not found in ${defaultDir}, skipping`));
|
|
7654
7712
|
continue;
|
|
7655
7713
|
}
|
|
7656
7714
|
let isSymlink = false;
|
|
@@ -7737,7 +7795,7 @@ async function listAvailablePlugins() {
|
|
|
7737
7795
|
// src/wrapper.ts
|
|
7738
7796
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, chmodSync, unlinkSync as unlinkSync2 } from "fs";
|
|
7739
7797
|
import { writeFile as writeFile2 } from "fs/promises";
|
|
7740
|
-
import { resolve, dirname } from "path";
|
|
7798
|
+
import { resolve as resolve2, dirname as dirname2 } from "path";
|
|
7741
7799
|
import { execSync } from "child_process";
|
|
7742
7800
|
function getClaudePath() {
|
|
7743
7801
|
try {
|
|
@@ -7787,7 +7845,7 @@ set "CLAUDE_CONFIG_DIR=${options.configDir}"
|
|
|
7787
7845
|
}
|
|
7788
7846
|
async function createWrapper(options) {
|
|
7789
7847
|
const { binaryPath, configDir } = options;
|
|
7790
|
-
const binDir =
|
|
7848
|
+
const binDir = dirname2(binaryPath);
|
|
7791
7849
|
if (!existsSync2(binDir)) {
|
|
7792
7850
|
mkdirSync2(binDir, { recursive: true });
|
|
7793
7851
|
}
|
|
@@ -7809,9 +7867,9 @@ function removeWrapper(binaryPath) {
|
|
|
7809
7867
|
}
|
|
7810
7868
|
}
|
|
7811
7869
|
function getDefaultBinaryPath(name) {
|
|
7812
|
-
const binDir = process.platform === "win32" ?
|
|
7870
|
+
const binDir = process.platform === "win32" ? resolve2(process.env.LOCALAPPDATA || "", "bun", "bin") : resolve2(process.env.HOME || "", ".local", "bin");
|
|
7813
7871
|
const binaryName = process.platform === "win32" ? `claude-${name}.cmd` : `claude-${name}`;
|
|
7814
|
-
return
|
|
7872
|
+
return resolve2(binDir, binaryName);
|
|
7815
7873
|
}
|
|
7816
7874
|
|
|
7817
7875
|
// src/version.ts
|
|
@@ -8160,6 +8218,9 @@ program2.command("auto-sync <name> <status>").description("Toggle auto-sync for
|
|
|
8160
8218
|
process.exit(1);
|
|
8161
8219
|
}
|
|
8162
8220
|
});
|
|
8221
|
+
program2.command("fix-symlinks [name...]").description("Fix broken symlinks for instances (auto-detects and repairs)").option("-a, --all", "Fix all instances").action(async (names, options) => {
|
|
8222
|
+
await handleFixSymlinks(names, options.all);
|
|
8223
|
+
});
|
|
8163
8224
|
program2.command("plugins").description("Manage enabled plugins for instances").argument("[action]", "Action to perform (list, enable, disable, copy)", "list").argument("[instance]", "Instance name (for list/enable/disable)", "").argument("[plugins...]", "Plugin IDs to enable/disable", []).action(async (action = "list", instanceName = "", plugins = []) => {
|
|
8164
8225
|
try {
|
|
8165
8226
|
switch (action) {
|
|
@@ -8317,6 +8378,55 @@ async function handlePluginsCopy(instanceName) {
|
|
|
8317
8378
|
const enabledCount = Object.values(defaultPlugins).filter((v) => v === true).length;
|
|
8318
8379
|
console.log(source_default.green(`\u2713 Copied ${enabledCount} enabled plugins to '${instanceName}'`));
|
|
8319
8380
|
}
|
|
8381
|
+
async function handleFixSymlinks(names, fixAll) {
|
|
8382
|
+
const instances = await listInstances();
|
|
8383
|
+
if (instances.length === 0) {
|
|
8384
|
+
console.log(source_default.yellow("No instances found."));
|
|
8385
|
+
return;
|
|
8386
|
+
}
|
|
8387
|
+
let instancesToFix;
|
|
8388
|
+
if (fixAll) {
|
|
8389
|
+
instancesToFix = instances;
|
|
8390
|
+
} else if (names.length > 0) {
|
|
8391
|
+
instancesToFix = names.map((name) => instances.find((i) => i.name === name)).filter(Boolean);
|
|
8392
|
+
} else {
|
|
8393
|
+
const { selected } = await import_prompts.default({
|
|
8394
|
+
type: "multiselect",
|
|
8395
|
+
name: "selected",
|
|
8396
|
+
message: "Select instances to fix:",
|
|
8397
|
+
choices: instances.map((i) => ({
|
|
8398
|
+
title: `${i.name} ${i.autoSync ? "(auto-sync)" : "(manual)"}`,
|
|
8399
|
+
value: i.name
|
|
8400
|
+
}))
|
|
8401
|
+
});
|
|
8402
|
+
instancesToFix = selected.map((name) => instances.find((i) => i.name === name)).filter(Boolean);
|
|
8403
|
+
}
|
|
8404
|
+
for (const instance of instancesToFix) {
|
|
8405
|
+
const diagnosis = detectBrokenSymlinks(instance.configDir);
|
|
8406
|
+
const needsFix = diagnosis.broken.length > 0;
|
|
8407
|
+
console.log(source_default.bold(`
|
|
8408
|
+
\uD83D\uDD0D ${instance.name}`));
|
|
8409
|
+
console.log(` Config: ${instance.configDir}`);
|
|
8410
|
+
console.log(` Auto-sync: ${instance.autoSync ? "enabled" : "disabled"}`);
|
|
8411
|
+
if (diagnosis.all.length === 0) {
|
|
8412
|
+
console.log(source_default.gray(" No symlinks found (manual mode)"));
|
|
8413
|
+
continue;
|
|
8414
|
+
}
|
|
8415
|
+
if (needsFix) {
|
|
8416
|
+
console.log(source_default.red(` \u274C Broken: ${diagnosis.broken.join(", ")}`));
|
|
8417
|
+
if (instance.autoSync) {
|
|
8418
|
+
await syncPluginsAndSkills(instance.configDir);
|
|
8419
|
+
console.log(source_default.green(` \u2705 Fixed: ${diagnosis.broken.join(", ")}`));
|
|
8420
|
+
} else {
|
|
8421
|
+
console.log(source_default.yellow(" \u26A0 Auto-sync is disabled. Enable it first."));
|
|
8422
|
+
}
|
|
8423
|
+
} else {
|
|
8424
|
+
console.log(source_default.green(` \u2705 All symlinks OK: ${diagnosis.all.join(", ")}`));
|
|
8425
|
+
}
|
|
8426
|
+
}
|
|
8427
|
+
console.log(source_default.bold(`
|
|
8428
|
+
\u2728 Done!`));
|
|
8429
|
+
}
|
|
8320
8430
|
program2.command("interactive").alias("i").description("Launch interactive mode for managing Claude Code instances").action(async () => {
|
|
8321
8431
|
try {
|
|
8322
8432
|
await runInteractiveMode();
|
|
@@ -8348,6 +8458,7 @@ async function runInteractiveMode() {
|
|
|
8348
8458
|
{ title: "\u2139\uFE0F Show instance details", value: "info" },
|
|
8349
8459
|
{ title: "\uD83D\uDD0C Manage plugins", value: "plugins" },
|
|
8350
8460
|
{ title: "\uD83D\uDD04 Toggle auto-sync", value: "autosync" },
|
|
8461
|
+
{ title: "\uD83D\uDD04 Re-sync symlinks", value: "resync" },
|
|
8351
8462
|
{ title: "\uD83D\uDDD1\uFE0F Remove instance", value: "remove" }
|
|
8352
8463
|
] : [],
|
|
8353
8464
|
{ title: "\uD83D\uDEAA Exit", value: "exit" }
|
|
@@ -8375,6 +8486,9 @@ async function runInteractiveMode() {
|
|
|
8375
8486
|
case "autosync":
|
|
8376
8487
|
await handleToggleAutoSync(instances);
|
|
8377
8488
|
break;
|
|
8489
|
+
case "resync":
|
|
8490
|
+
await handleFixSymlinks([], false);
|
|
8491
|
+
break;
|
|
8378
8492
|
case "remove":
|
|
8379
8493
|
await handleRemoveInstance(instances);
|
|
8380
8494
|
break;
|
|
@@ -8719,6 +8833,7 @@ async function handleToggleAutoSync(instances) {
|
|
|
8719
8833
|
message: `Auto-sync is currently ${source_default.cyan(currentStatus ? "enabled" : "disabled")}. What would you like to do?`,
|
|
8720
8834
|
choices: [
|
|
8721
8835
|
{ title: currentStatus ? "Turn off (copy files locally)" : "Turn on (use symlinks)", value: "toggle" },
|
|
8836
|
+
...currentStatus ? [{ title: "Force re-sync now (rebuild symlinks)", value: "force-sync" }] : [],
|
|
8722
8837
|
{ title: "Cancel", value: "cancel" }
|
|
8723
8838
|
],
|
|
8724
8839
|
initial: 0
|
|
@@ -8727,11 +8842,20 @@ async function handleToggleAutoSync(instances) {
|
|
|
8727
8842
|
console.log(source_default.yellow("\u2717 Cancelled"));
|
|
8728
8843
|
return;
|
|
8729
8844
|
}
|
|
8730
|
-
|
|
8731
|
-
|
|
8845
|
+
try {
|
|
8846
|
+
if (action === "force-sync") {
|
|
8847
|
+
console.log(source_default.bold(`
|
|
8848
|
+
\uD83D\uDD04 Re-syncing symlinks for '${instanceName}'...
|
|
8849
|
+
`));
|
|
8850
|
+
await syncPluginsAndSkills(instance.configDir);
|
|
8851
|
+
console.log(source_default.green(`
|
|
8852
|
+
\u2713 Symlinks rebuilt for '${instanceName}'`));
|
|
8853
|
+
return;
|
|
8854
|
+
}
|
|
8855
|
+
const newStatus = !currentStatus;
|
|
8856
|
+
console.log(source_default.bold(`
|
|
8732
8857
|
\uD83D\uDD04 ${newStatus ? "Enabling" : "Disabling"} auto-sync for '${instanceName}'...
|
|
8733
8858
|
`));
|
|
8734
|
-
try {
|
|
8735
8859
|
await updateInstanceAutoSync(instanceName, newStatus);
|
|
8736
8860
|
if (newStatus) {
|
|
8737
8861
|
await syncPluginsAndSkills(instance.configDir);
|