Skip to content

Commit 20446e9

Browse files
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0aaca5a commit 20446e9

2 files changed

Lines changed: 70 additions & 5 deletions

File tree

src/commands/install.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,15 @@ type ResolvedPluginPath = {
131131
}
132132

133133
async function resolvePluginPath(input: string): Promise<ResolvedPluginPath> {
134-
const directPath = path.resolve(input)
135-
if (await pathExists(directPath)) return { path: directPath }
136-
137-
const pluginsPath = path.join(process.cwd(), "plugins", input)
138-
if (await pathExists(pluginsPath)) return { path: pluginsPath }
134+
// Only treat as a local path if it explicitly looks like one
135+
if (input.startsWith(".") || input.startsWith("/") || input.startsWith("~")) {
136+
const expanded = expandHome(input)
137+
const directPath = path.resolve(expanded)
138+
if (await pathExists(directPath)) return { path: directPath }
139+
throw new Error(`Local plugin path not found: ${directPath}`)
140+
}
139141

142+
// Otherwise, always fetch the latest from GitHub
140143
return await resolveGitHubPluginPath(input)
141144
}
142145

tests/cli.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,68 @@ describe("CLI", () => {
180180
expect(await exists(path.join(tempRoot, ".config", "opencode", "agents", "repo-research-analyst.md"))).toBe(true)
181181
})
182182

183+
test("install by name ignores same-named local directory", async () => {
184+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-"))
185+
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-workspace-"))
186+
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-shadow-repo-"))
187+
188+
// Create a directory with the plugin name that is NOT a valid plugin
189+
const shadowDir = path.join(workspaceRoot, "compound-engineering")
190+
await fs.mkdir(shadowDir, { recursive: true })
191+
await fs.writeFile(path.join(shadowDir, "README.md"), "Not a plugin")
192+
193+
// Set up a fake GitHub source with a valid plugin
194+
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
195+
const pluginRoot = path.join(repoRoot, "plugins", "compound-engineering")
196+
await fs.mkdir(path.dirname(pluginRoot), { recursive: true })
197+
await fs.cp(fixtureRoot, pluginRoot, { recursive: true })
198+
199+
const gitEnv = {
200+
...process.env,
201+
GIT_AUTHOR_NAME: "Test",
202+
GIT_AUTHOR_EMAIL: "test@example.com",
203+
GIT_COMMITTER_NAME: "Test",
204+
GIT_COMMITTER_EMAIL: "test@example.com",
205+
}
206+
await runGit(["init"], repoRoot, gitEnv)
207+
await runGit(["add", "."], repoRoot, gitEnv)
208+
await runGit(["commit", "-m", "fixture"], repoRoot, gitEnv)
209+
210+
const projectRoot = path.join(import.meta.dir, "..")
211+
const proc = Bun.spawn([
212+
"bun",
213+
"run",
214+
path.join(projectRoot, "src", "index.ts"),
215+
"install",
216+
"compound-engineering",
217+
"--to",
218+
"opencode",
219+
"--output",
220+
tempRoot,
221+
], {
222+
cwd: workspaceRoot,
223+
stdout: "pipe",
224+
stderr: "pipe",
225+
env: {
226+
...process.env,
227+
HOME: tempRoot,
228+
COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot,
229+
},
230+
})
231+
232+
const exitCode = await proc.exited
233+
const stdout = await new Response(proc.stdout).text()
234+
const stderr = await new Response(proc.stderr).text()
235+
236+
if (exitCode !== 0) {
237+
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
238+
}
239+
240+
// Should succeed by fetching from GitHub, NOT failing on the local shadow directory
241+
expect(stdout).toContain("Installed compound-engineering")
242+
expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true)
243+
})
244+
183245
test("convert writes OpenCode output", async () => {
184246
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-"))
185247
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")

0 commit comments

Comments
 (0)