|
| 1 | +import { lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; |
| 2 | +import os from "node:os"; |
| 3 | +import path from "node:path"; |
| 4 | +import { fileURLToPath } from "node:url"; |
| 5 | + |
| 6 | +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 7 | +const claudeHome = process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude"); |
| 8 | +const manifestPath = path.join(root, ".claude-plugin", "plugin.json"); |
| 9 | +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); |
| 10 | +const marketplace = "codex-subagents-local"; |
| 11 | +const pluginName = manifest.name; |
| 12 | +const version = manifest.version; |
| 13 | +const installedPluginsPath = path.join(claudeHome, "plugins", "installed_plugins.json"); |
| 14 | +const marketplacePluginPath = path.join( |
| 15 | + claudeHome, |
| 16 | + "plugins", |
| 17 | + "marketplaces", |
| 18 | + marketplace, |
| 19 | + "plugins", |
| 20 | + pluginName, |
| 21 | +); |
| 22 | +const installPath = path.join(claudeHome, "plugins", "cache", marketplace, pluginName, version); |
| 23 | + |
| 24 | +async function pathExists(targetPath) { |
| 25 | + try { |
| 26 | + await lstat(targetPath); |
| 27 | + return true; |
| 28 | + } catch (error) { |
| 29 | + if (error?.code === "ENOENT") return false; |
| 30 | + throw error; |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +async function resolvedPath(targetPath) { |
| 35 | + try { |
| 36 | + return await realpath(targetPath); |
| 37 | + } catch { |
| 38 | + return null; |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +function backupPath(targetPath) { |
| 43 | + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); |
| 44 | + return `${targetPath}.backup-${stamp}`; |
| 45 | +} |
| 46 | + |
| 47 | +async function replaceWithSymlink(linkPath, targetPath) { |
| 48 | + await mkdir(path.dirname(linkPath), { recursive: true }); |
| 49 | + const currentTarget = await resolvedPath(linkPath); |
| 50 | + if (currentTarget === targetPath) return { path: linkPath, changed: false }; |
| 51 | + |
| 52 | + if (await pathExists(linkPath)) { |
| 53 | + const backup = backupPath(linkPath); |
| 54 | + await rename(linkPath, backup); |
| 55 | + console.log(`Moved existing ${linkPath} to ${backup}`); |
| 56 | + } |
| 57 | + |
| 58 | + await symlink(targetPath, linkPath, "dir"); |
| 59 | + return { path: linkPath, changed: true }; |
| 60 | +} |
| 61 | + |
| 62 | +async function ensureInstalledPluginsEntry() { |
| 63 | + await mkdir(path.dirname(installedPluginsPath), { recursive: true }); |
| 64 | + let data = { version: 2, plugins: {} }; |
| 65 | + if (await pathExists(installedPluginsPath)) { |
| 66 | + data = JSON.parse(await readFile(installedPluginsPath, "utf8")); |
| 67 | + } |
| 68 | + |
| 69 | + data.version = data.version ?? 2; |
| 70 | + data.plugins = data.plugins ?? {}; |
| 71 | + |
| 72 | + const key = `${pluginName}@${marketplace}`; |
| 73 | + const entries = Array.isArray(data.plugins[key]) ? data.plugins[key] : []; |
| 74 | + const existing = entries.find((entry) => entry.scope === "user" && entry.version === version); |
| 75 | + const now = new Date().toISOString(); |
| 76 | + const entry = { |
| 77 | + scope: "user", |
| 78 | + installPath, |
| 79 | + version, |
| 80 | + installedAt: existing?.installedAt ?? now, |
| 81 | + lastUpdated: now, |
| 82 | + gitCommitSha: "dev-symlink", |
| 83 | + }; |
| 84 | + |
| 85 | + data.plugins[key] = [entry, ...entries.filter((candidate) => candidate !== existing)]; |
| 86 | + await writeFile(installedPluginsPath, `${JSON.stringify(data, null, 2)}\n`); |
| 87 | + return entry; |
| 88 | +} |
| 89 | + |
| 90 | +const marketplaceLink = await replaceWithSymlink(marketplacePluginPath, root); |
| 91 | +const cacheLink = await replaceWithSymlink(installPath, root); |
| 92 | +const entry = await ensureInstalledPluginsEntry(); |
| 93 | + |
| 94 | +// Remove stale Claude marker files that can be left behind when replacing cache copies. |
| 95 | +await rm(path.join(installPath, ".in_use"), { force: true }); |
| 96 | + |
| 97 | +console.log(`Marketplace plugin path: ${marketplaceLink.path} -> ${root}`); |
| 98 | +console.log(`Installed plugin path: ${cacheLink.path} -> ${root}`); |
| 99 | +console.log(`Installed plugin entry: ${entry.installPath}`); |
| 100 | +console.log("Claude Code CLI and Claude Desktop CLI share this ~/.claude plugin install."); |
0 commit comments