From 20446e9add983a9ee15bfa8f5a616d2f0af0ce6f Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Thu, 12 Feb 2026 15:24:58 -0600 Subject: [PATCH] Fix: install by name always fetches from GitHub (#180) * feat(cursor): add Cursor CLI as target provider Add converter, writer, types, and tests for converting Claude Code plugins to Cursor-compatible format (.mdc rules, commands, skills, mcp.json). Agents become Agent Requested rules (alwaysApply: false), commands are plain markdown, skills copy directly, MCP is 1:1 JSON. * docs: add Cursor spec and update README with cursor target * chore: bump CLI version to 0.5.0 for cursor target Co-Authored-By: Claude Opus 4.6 * docs: note Cursor IDE + CLI compatibility in README * fix: install by name always fetches from GitHub Previously, `install compound-engineering` would resolve to any local directory named `compound-engineering` in the current working directory before trying GitHub. This broke installs when users had a same-named directory that wasn't a valid plugin. Now bare names always go to GitHub. Only explicit paths (starting with ./ or / or ~) are treated as local paths. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/commands/install.ts | 13 +++++---- tests/cli.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/commands/install.ts b/src/commands/install.ts index cdaa34f..4511f6e 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -131,12 +131,15 @@ type ResolvedPluginPath = { } async function resolvePluginPath(input: string): Promise { - const directPath = path.resolve(input) - if (await pathExists(directPath)) return { path: directPath } - - const pluginsPath = path.join(process.cwd(), "plugins", input) - if (await pathExists(pluginsPath)) return { path: pluginsPath } + // Only treat as a local path if it explicitly looks like one + if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) { + const expanded = expandHome(input) + const directPath = path.resolve(expanded) + if (await pathExists(directPath)) return { path: directPath } + throw new Error(`Local plugin path not found: ${directPath}`) + } + // Otherwise, always fetch the latest from GitHub return await resolveGitHubPluginPath(input) } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 1caf903..2a1ce33 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -180,6 +180,68 @@ describe("CLI", () => { expect(await exists(path.join(tempRoot, ".config", "opencode", "agents", "repo-research-analyst.md"))).toBe(true) }) + test("install by name ignores same-named local directory", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-")) + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-workspace-")) + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-repo-")) + + // Create a directory with the plugin name that is NOT a valid plugin + const shadowDir = path.join(workspaceRoot, "compound-engineering") + await fs.mkdir(shadowDir, { recursive: true }) + await fs.writeFile(path.join(shadowDir, "README.md"), "Not a plugin") + + // Set up a fake GitHub source with a valid plugin + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + const pluginRoot = path.join(repoRoot, "plugins", "compound-engineering") + await fs.mkdir(path.dirname(pluginRoot), { recursive: true }) + await fs.cp(fixtureRoot, pluginRoot, { recursive: true }) + + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + } + await runGit(["init"], repoRoot, gitEnv) + await runGit(["add", "."], repoRoot, gitEnv) + await runGit(["commit", "-m", "fixture"], repoRoot, gitEnv) + + const projectRoot = path.join(import.meta.dir, "..") + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "install", + "compound-engineering", + "--to", + "opencode", + "--output", + tempRoot, + ], { + cwd: workspaceRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: tempRoot, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + // Should succeed by fetching from GitHub, NOT failing on the local shadow directory + expect(stdout).toContain("Installed compound-engineering") + expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true) + }) + test("convert writes OpenCode output", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-")) const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")