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.
Files changed (3) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli.js +173 -49
  3. 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, unlinkSync, readlinkSync, rmSync } from "fs";
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 && symlinkDirs.includes(entry)) {
7487
- if (existsSync(targetPath)) {
7488
- try {
7489
- readlinkSync(targetPath);
7490
- unlinkSync(targetPath);
7491
- } catch {
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 symlinkDirs) {
7619
- const targetPath = join(configDir, dir);
7620
- const sourcePath = join(defaultDir, dir);
7621
- if (!existsSync(sourcePath)) {
7622
- console.log(source_default.yellow(` \u26A0 Source ${dir} not found in ~/.claude, skipping`));
7623
- continue;
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 symlinkDirs) {
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 ~/.claude, skipping`));
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 = dirname(binaryPath);
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" ? resolve(process.env.LOCALAPPDATA || "", "bun", "bin") : resolve(process.env.HOME || "", ".local", "bin");
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 resolve(binDir, binaryName);
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
- const newStatus = !currentStatus;
8731
- console.log(source_default.bold(`
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multi",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Manage multiple Claude Code instances with different aliases and configurations",
5
5
  "type": "module",
6
6
  "bin": {