Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/add-plugin-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Add Plugin CI

on:
push:
paths:
- ".github/workflows/add-plugin-ci.yml"
- "packages/add-plugin/**"
pull_request:
paths:
- ".github/workflows/add-plugin-ci.yml"
- "packages/add-plugin/**"

jobs:
add-plugin:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/add-plugin

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20

- name: Install dependencies
run: npm install

- name: Build
run: npm run build

- name: Test
run: npm test
2 changes: 1 addition & 1 deletion packages/add-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { installPlugins } from "./lib/install.js";
import { c, S, banner, header, footer, step, stepDone, stepActive, stepError, barLine, barEmpty, barDebug, error, multiSelect, setDebug, type MultiSelectOption } from "./lib/ui.js";
import { setVersion, track } from "./lib/telemetry.js";

setVersion("1.0.1");
setVersion("1.2.8");

const { values, positionals } = parseArgs({
args: process.argv.slice(2),
Expand Down
57 changes: 49 additions & 8 deletions packages/add-plugin/lib/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
*/

import { join, relative } from "path";
import { mkdir, cp, readFile, writeFile } from "fs/promises";
import { mkdir, cp, readFile, writeFile, rm } from "fs/promises";
import { existsSync } from "fs";
import { execSync } from "child_process";
import { homedir } from "os";
import { createHash } from "crypto";
import type { DiscoveredPlugin } from "./discover.js";
import type { Target } from "./targets.js";
import { c, step, stepDone, stepError, barLine, barEmpty, barDebug } from "./ui.js";
Expand All @@ -37,20 +38,60 @@ export async function installPlugins(
source: string,
): Promise<void> {
switch (target.id) {
case "claude-code":
await installToClaudeCode(plugins, scope, repoPath, source);
case "claude-code": {
const workspace = await stageInstallWorkspace(plugins, repoPath, target.id);
await installToClaudeCode(workspace.plugins, scope, workspace.repoPath, source);
break;
case "cursor":
await installToCursor(plugins, scope, repoPath, source);
}
case "cursor": {
if (cachePopulated) return;
const workspace = await stageInstallWorkspace(plugins, repoPath, target.id);
await installToCursor(workspace.plugins, scope, workspace.repoPath, source);
break;
case "codex":
await installToCodex(plugins, scope, repoPath, source);
}
case "codex": {
const workspace = await stageInstallWorkspace(plugins, repoPath, target.id);
await installToCodex(workspace.plugins, scope, workspace.repoPath, source);
break;
}
default:
throw new Error(`Unsupported target: ${target.id}`);
}
}

interface InstallWorkspace {
repoPath: string;
plugins: DiscoveredPlugin[];
}

export async function stageInstallWorkspace(
plugins: DiscoveredPlugin[],
repoPath: string,
targetId: string,
stagingBaseDir = join(homedir(), ".cache", "plugins", ".install-staging"),
): Promise<InstallWorkspace> {
const stageKey = createHash("sha1").update(repoPath).digest("hex");
const stageRoot = join(stagingBaseDir, stageKey, targetId);
const stagedRepoPath = join(stageRoot, "repo");

await mkdir(stageRoot, { recursive: true });
await rm(stagedRepoPath, { recursive: true, force: true });
await cp(repoPath, stagedRepoPath, { recursive: true });

const stagedPlugins = plugins.map((plugin) => {
const relPath = relative(repoPath, plugin.path);
return {
...plugin,
path: relPath === "" ? stagedRepoPath : join(stagedRepoPath, relPath),
};
});

return {
repoPath: stagedRepoPath,
plugins: stagedPlugins,
};
}

// ---------------------------------------------------------------------------
// Claude Code installer (uses the `claude` CLI)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -706,7 +747,7 @@ function findClaude(): string {
* - Generate <vendorDir>/plugin.json if neither .plugin/ nor vendor dir exist
* - Translate ${PLUGIN_ROOT} -> ${<VENDOR_ENV_VAR>} in config files
*/
async function preparePluginDirForVendor(
export async function preparePluginDirForVendor(
plugin: DiscoveredPlugin,
vendorDir: string,
envVar: string,
Expand Down
6 changes: 4 additions & 2 deletions packages/add-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plugins",
"version": "1.2.7",
"version": "1.2.8",
"description": "Install open-plugin format plugins into agent tools",
"type": "module",
"bin": {
Expand All @@ -11,9 +11,11 @@
],
"scripts": {
"build": "tsup",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test": "tsx --test test/*.test.ts"
},
"devDependencies": {
"tsx": "^4.20.6",
"tsup": "^8",
"typescript": "^5"
}
Expand Down
113 changes: 113 additions & 0 deletions packages/add-plugin/test/install-staging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { DiscoveredPlugin } from "../lib/discover.js";
import { preparePluginDirForVendor, stageInstallWorkspace } from "../lib/install.js";

function createPlugin(repoPath: string, pluginPath: string): DiscoveredPlugin {
return {
name: "claude-hooks",
version: "1.0.0",
description: "Regression test fixture",
path: pluginPath,
marketplace: undefined,
skills: [],
commands: [],
agents: [],
rules: [],
hasHooks: true,
hasMcp: false,
hasLsp: false,
manifest: null,
explicitSkillPaths: undefined,
marketplaceEntry: undefined,
};
}

test("staged installs isolate plugin-root rewrites per target", async () => {
const root = await mkdtemp(join(tmpdir(), "add-plugin-install-test-"));

try {
const repoPath = join(root, "repo");
const pluginPath = join(repoPath, "plugins", "claude-hooks");
const hooksPath = join(pluginPath, "hooks", "hooks.json");

await mkdir(join(pluginPath, ".plugin"), { recursive: true });
await mkdir(join(pluginPath, "hooks"), { recursive: true });

await writeFile(
join(pluginPath, ".plugin", "plugin.json"),
JSON.stringify(
{
name: "claude-hooks",
version: "1.0.0",
description: "Regression test fixture",
},
null,
2,
),
);

await writeFile(
hooksPath,
JSON.stringify(
{
hooks: {
SessionStart: [
{
hooks: [
{
type: "command",
command: "${PLUGIN_ROOT}/hooks/inject-claude-md.mjs",
},
],
},
],
},
},
null,
2,
),
);

const plugin = createPlugin(repoPath, pluginPath);
const stagingBase = join(root, "staging");

const claudeWorkspace = await stageInstallWorkspace([plugin], repoPath, "claude-code", stagingBase);
const codexWorkspace = await stageInstallWorkspace([plugin], repoPath, "codex", stagingBase);

await preparePluginDirForVendor(
claudeWorkspace.plugins[0]!,
".claude-plugin",
"CLAUDE_PLUGIN_ROOT",
);
await preparePluginDirForVendor(
codexWorkspace.plugins[0]!,
".codex-plugin",
"CODEX_PLUGIN_ROOT",
);

const originalHooks = await readFile(hooksPath, "utf-8");
const claudeHooks = await readFile(
join(claudeWorkspace.plugins[0]!.path, "hooks", "hooks.json"),
"utf-8",
);
const codexHooks = await readFile(
join(codexWorkspace.plugins[0]!.path, "hooks", "hooks.json"),
"utf-8",
);

assert.match(originalHooks, /\$\{PLUGIN_ROOT\}/);
assert.doesNotMatch(originalHooks, /\$\{CLAUDE_PLUGIN_ROOT\}|\$\{CODEX_PLUGIN_ROOT\}/);

assert.match(claudeHooks, /\$\{CLAUDE_PLUGIN_ROOT\}/);
assert.doesNotMatch(claudeHooks, /\$\{CODEX_PLUGIN_ROOT\}/);

assert.match(codexHooks, /\$\{CODEX_PLUGIN_ROOT\}/);
assert.doesNotMatch(codexHooks, /\$\{CLAUDE_PLUGIN_ROOT\}/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
Loading