Skip to content

Commit d6d0720

Browse files
xuiocodex
andcommitted
Link Claude plugin installs for development
Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 5c0a827 commit d6d0720

5 files changed

Lines changed: 206 additions & 1 deletion

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,24 @@ claude --plugin-dir .
9292

9393
The plugin manifest points Claude Code at `dist/index.js`, so run `npm run build` after changing TypeScript source. The built file is committed so the plugin can be loaded directly from a clone.
9494

95+
For local development against the installed plugin, link Claude's installed cache entry directly to this repository:
96+
97+
```sh
98+
npm run dev:link
99+
```
100+
101+
This makes both the Homebrew Claude Code CLI and the Claude Desktop bundled Claude Code CLI read the same working tree through `~/.claude/plugins/cache/codex-subagents-local/codex-subagents/<version>`. TypeScript source still needs to be rebuilt into `dist/index.js`; keep this running while editing MCP code:
102+
103+
```sh
104+
npm run dev:watch
105+
```
106+
95107
## Development
96108

97109
```sh
98110
npm install
99111
npm run build
112+
npm run dev:link
100113
npm test
101114
npm run test:comprehensive
102115
npm run validate:plugin

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,21 @@
2020
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node' && chmod 755 dist/index.js",
2121
"test": "vitest run",
2222
"test:watch": "vitest",
23+
"dev:link": "node scripts/link-claude-dev-plugin.mjs",
24+
"dev:watch": "node scripts/watch-dist.mjs",
2325
"validate:plugin": "claude plugin validate .",
2426
"smoke:mcp": "node test/smoke-mcp.mjs",
2527
"test:reliability": "node test/reliability-matrix.mjs",
2628
"test:stress": "node test/stress-mcp.mjs",
2729
"test:progress": "node test/progress-mcp.mjs",
2830
"test:advanced": "node test/advanced-mcp.mjs",
31+
"test:dev-link": "node test/dev-link.mjs",
2932
"test:codex-runtime": "node test/codex-runtime-probe.mjs",
3033
"test:claude-orchestration": "node test/claude-orchestration.mjs",
3134
"test:claude-autodiscovery": "node test/claude-autodiscovery.mjs",
3235
"test:claude-large-output": "node test/claude-large-output.mjs",
3336
"test:claude-real-codex": "node test/claude-real-codex.mjs",
34-
"test:ci": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced",
37+
"test:ci": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced && npm run test:dev-link",
3538
"test:comprehensive": "npm run build && npm test && npm run smoke:mcp && npm run test:reliability && npm run test:stress && npm run test:progress && npm run test:advanced && npm run test:codex-runtime && npm run validate:plugin && npm run test:claude-desktop",
3639
"test:claude-desktop": "node test/claude-desktop-cli.mjs"
3740
},

scripts/link-claude-dev-plugin.mjs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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.");

scripts/watch-dist.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { chmod } from "node:fs/promises";
2+
import { context } from "esbuild";
3+
4+
const outfile = "dist/index.js";
5+
6+
const ctx = await context({
7+
entryPoints: ["src/index.ts"],
8+
bundle: true,
9+
platform: "node",
10+
target: "node20",
11+
format: "esm",
12+
outfile,
13+
banner: {
14+
js: "#!/usr/bin/env node",
15+
},
16+
plugins: [
17+
{
18+
name: "chmod-dist",
19+
setup(build) {
20+
build.onEnd(async (result) => {
21+
if (result.errors.length > 0) return;
22+
await chmod(outfile, 0o755);
23+
console.log(`rebuilt ${outfile}`);
24+
});
25+
},
26+
},
27+
],
28+
});
29+
30+
await ctx.watch();
31+
console.log(`watching src/index.ts and dependencies; writing ${outfile}`);

test/dev-link.mjs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { spawnSync } from "node:child_process";
2+
import { mkdtemp, readFile, realpath, rm } from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
5+
6+
function assert(condition, message, details) {
7+
if (!condition) {
8+
throw new Error(`${message}${details ? `\n${JSON.stringify(details, null, 2)}` : ""}`);
9+
}
10+
}
11+
12+
const root = process.cwd();
13+
const manifest = JSON.parse(await readFile(path.join(root, ".claude-plugin/plugin.json"), "utf8"));
14+
const claudeHome = await mkdtemp(path.join(os.tmpdir(), "codex-subagents-claude-home-"));
15+
const installPath = path.join(
16+
claudeHome,
17+
`plugins/cache/codex-subagents-local/codex-subagents/${manifest.version}`,
18+
);
19+
const marketplacePath = path.join(
20+
claudeHome,
21+
"plugins/marketplaces/codex-subagents-local/plugins/codex-subagents",
22+
);
23+
24+
try {
25+
for (const pass of [1, 2]) {
26+
const result = spawnSync("node", ["scripts/link-claude-dev-plugin.mjs"], {
27+
cwd: root,
28+
encoding: "utf8",
29+
shell: false,
30+
env: {
31+
...process.env,
32+
CLAUDE_HOME: claudeHome,
33+
},
34+
});
35+
36+
const output = [result.stdout, result.stderr].filter(Boolean).join("");
37+
assert(result.status === 0, `dev link pass ${pass} should succeed`, output);
38+
assert((await realpath(installPath)) === root, "installed plugin cache should point at repo", {
39+
installPath,
40+
output,
41+
});
42+
assert((await realpath(marketplacePath)) === root, "marketplace plugin should point at repo", {
43+
marketplacePath,
44+
output,
45+
});
46+
}
47+
48+
const installed = JSON.parse(
49+
await readFile(path.join(claudeHome, "plugins/installed_plugins.json"), "utf8"),
50+
);
51+
const entry = installed.plugins["codex-subagents@codex-subagents-local"]?.[0];
52+
assert(entry?.installPath === installPath, "installed plugin entry should use the cache symlink", entry);
53+
assert(entry?.gitCommitSha === "dev-symlink", "installed plugin entry should be marked as dev symlink", entry);
54+
55+
console.log("Claude dev-link test passed");
56+
} finally {
57+
await rm(claudeHome, { recursive: true, force: true });
58+
}

0 commit comments

Comments
 (0)