-
Notifications
You must be signed in to change notification settings - Fork 0
Add plugin/marketplace commands #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
- Export function to allow reuse in marketplace.ts - Use HOME env var with homedir() fallback for consistency
- Add enablePlugin() to add plugins to .construct.json - Add disablePlugin() to remove plugins from config - Add listEnabledPlugins() to show enabled plugins - Include comprehensive test coverage
- Add addMarketplace() to clone GitHub marketplaces - Add removeMarketplace() to unregister and optionally delete - Add updateMarketplace() to git pull updates - Add listMarketplaces() to show known marketplaces - Support GitHub URL and owner/repo shorthand formats - Include comprehensive test coverage
- Add 'construct plugin' command for plugin management - Add 'plugin enable/disable' for toggling plugins - Add 'plugin --list-enabled' flag - Add 'plugin marketplace' with add/remove/update/list subcommands - Add mutual exclusivity for update [name] and --all flags - Improve error messages for invalid subcommand usage
Document the plugin and marketplace commands including: - CLI usage examples - Implementation details - Error handling behavior - Testing strategy
Co-authored-by: Copilot <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds comprehensive plugin and marketplace management commands to enable managing plugins and marketplaces from the command line without needing to interact with Claude directly.
Changes:
- New CLI commands for enabling/disabling plugins and managing marketplaces (add, remove, update, list)
- Business logic implementations for plugin and marketplace operations with comprehensive error handling
- Extensive test coverage for all new functionality with proper mocking
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/scanner.ts | Exports getKnownMarketplacesPath function and adds HOME environment variable fallback for better testability |
| src/plugin.ts | Implements plugin enable/disable/list functionality with config file management |
| src/plugin.test.ts | Comprehensive unit tests for plugin operations covering success and error cases |
| src/marketplace.ts | Implements marketplace add/remove/update/list operations with git integration |
| src/marketplace.test.ts | Comprehensive unit tests for marketplace operations with mocked git commands |
| src/cli.ts | Extends CLI parser with new plugin and marketplace subcommands using yargs |
| index.ts | Routes plugin and marketplace commands to appropriate handlers |
| plugin.md | Documentation describing the implementation requirements and command usage |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| test("addMarketplace() parses full GitHub URL correctly", async () => { | ||
| const restoreSpawn = mockSpawnSync((cmd) => { | ||
| if (cmd[1] === "clone") { | ||
| const installLocation = cmd[3]; | ||
| if (!installLocation) { | ||
| throw new Error("Missing install location"); | ||
| } | ||
| mkdirSync(join(installLocation, ".claude-plugin"), { recursive: true }); | ||
| writeFileSync( | ||
| join(installLocation, ".claude-plugin", "marketplace.json"), | ||
| JSON.stringify({ name: "repo-name", plugins: [] }, null, 2), | ||
| ); | ||
| } | ||
| return { exitCode: 0 }; | ||
| }); | ||
|
|
||
| try { | ||
| await addMarketplace("https://github.com/owner/repo-name", paths); | ||
| } finally { | ||
| restoreSpawn(); | ||
| } | ||
|
|
||
| const known = readKnownMarketplaces(); | ||
| const entry = known["repo-name"]; | ||
| expect(entry).toBeDefined(); | ||
| if (!entry) { | ||
| throw new Error("Missing marketplace entry"); | ||
| } | ||
| expect(entry.source.repo).toBe("owner/repo-name"); | ||
| }); | ||
|
|
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test suite doesn't include a test case for parsing GitHub URLs with the .git suffix (e.g., "https://github.com/owner/repo.git"). The regex on line 78 of marketplace.ts handles this case with the optional "(?:\.git)?" pattern, but there's no test to verify this works correctly. Consider adding a test case to ensure URLs with .git suffix are parsed correctly.
src/cli.ts
Outdated
| conflicts: "all", | ||
| }) | ||
| .option("all", { | ||
| type: "boolean", | ||
| alias: "a", | ||
| description: "Update all git-based marketplaces", | ||
| default: false, |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 'conflicts' option on line 154 is set to conflict with 'all', but this might not work as expected with yargs. The 'conflicts' option expects an array of option names or a string. Since 'all' is defined as an option and 'name' is a positional, yargs might not properly enforce this conflict. Consider testing this behavior or using custom validation to ensure users can't provide both a specific name and --all flag simultaneously.
| conflicts: "all", | |
| }) | |
| .option("all", { | |
| type: "boolean", | |
| alias: "a", | |
| description: "Update all git-based marketplaces", | |
| default: false, | |
| }) | |
| .option("all", { | |
| type: "boolean", | |
| alias: "a", | |
| description: "Update all git-based marketplaces", | |
| default: false, | |
| }) | |
| .check((args) => { | |
| if (args.name && args.all) { | |
| throw new Error("Specify either a marketplace name or --all, but not both."); | |
| } | |
| return true; |
| const githubUrlMatch = target.match( | ||
| /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/, | ||
| ); | ||
| if (githubUrlMatch) { | ||
| const owner = githubUrlMatch[1]; | ||
| const repoName = githubUrlMatch[2]; | ||
| if (!owner || !repoName) { | ||
| return null; | ||
| } | ||
| const repo = `${owner}/${repoName}`; | ||
| return { repo, name: repoName }; | ||
| } |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex on line 78 allows an optional trailing slash, but it doesn't handle URLs that might have additional path segments or query parameters. Consider whether URLs like "https://github.com/owner/repo/tree/main" should be rejected or handled. The current implementation would reject such URLs, which is likely correct behavior, but it may be worth documenting this limitation.
src/marketplace.ts
Outdated
| mkdirSync(dirname(path), { recursive: true }); | ||
| await Bun.write(path, JSON.stringify(data, null, 2)); |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The writeKnownMarketplaces function on lines 65-72 doesn't have error handling. If the write operation fails (due to permissions, disk full, etc.), the error will propagate as an unhandled promise rejection. Consider adding try-catch error handling and providing a meaningful error message to the user.
| mkdirSync(dirname(path), { recursive: true }); | |
| await Bun.write(path, JSON.stringify(data, null, 2)); | |
| try { | |
| mkdirSync(dirname(path), { recursive: true }); | |
| await Bun.write(path, JSON.stringify(data, null, 2)); | |
| } catch (error) { | |
| console.error( | |
| `Error: Failed to write known marketplaces file at "${path}": ${ | |
| error instanceof Error ? error.message : String(error) | |
| }`, | |
| ); | |
| process.exit(1); | |
| } |
| function parseMarketplaceTarget( | ||
| target: string, | ||
| ): { repo: string; name: string } | null { | ||
| const githubUrlMatch = target.match( | ||
| /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/, | ||
| ); | ||
| if (githubUrlMatch) { | ||
| const owner = githubUrlMatch[1]; | ||
| const repoName = githubUrlMatch[2]; | ||
| if (!owner || !repoName) { | ||
| return null; | ||
| } | ||
| const repo = `${owner}/${repoName}`; | ||
| return { repo, name: repoName }; | ||
| } | ||
|
|
||
| const shorthandMatch = target.match(/^([^/]+)\/([^/]+)$/); | ||
| if (shorthandMatch) { | ||
| const owner = shorthandMatch[1]; | ||
| const repoName = shorthandMatch[2]; | ||
| if (!owner || !repoName) { | ||
| return null; | ||
| } | ||
| return { repo: `${owner}/${repoName}`, name: repoName }; | ||
| } | ||
|
|
||
| return null; | ||
| } |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parseMarketplaceTarget function doesn't validate that the owner and repo name parts contain valid characters for GitHub repositories. GitHub repository names have restrictions (alphanumeric, hyphens, underscores, dots). Consider adding validation to reject invalid characters early rather than letting the git clone fail with a cryptic error.
| const marketplacesRoot = getMarketplacesRoot(paths); | ||
| mkdirSync(marketplacesRoot, { recursive: true }); | ||
| const installLocation = join(marketplacesRoot, parsed.name); | ||
| const repoUrl = `https://github.com/${parsed.repo}.git`; |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The marketplace name is derived directly from the repository name and used to construct the installLocation path without sanitization. While GitHub repository names have character restrictions, this could potentially be exploited if the parsing logic is bypassed or if directory-based marketplaces are added in the future. Consider adding explicit path sanitization to prevent directory traversal attacks (e.g., names containing "..", "/", etc.).
| for (const [name] of githubMarketplaces) { | ||
| await updateMarketplace(name, paths); | ||
| } |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The updateAllMarketplaces function prints the summary message after updating individual marketplaces. However, each call to updateMarketplace already prints "Updated marketplace: ". Consider whether printing the final summary is still needed, as it may be redundant. If the summary is intended to show the total count, it should be printed even when no marketplaces are updated (currently it would show "Updated 0 marketplace(s)" only after processing, which is correct).
src/marketplace.ts
Outdated
| if (result.exitCode !== 0) { | ||
| const details = | ||
| decodeOutput(result.stderr) || | ||
| decodeOutput(result.stdout) || | ||
| `exit code ${result.exitCode ?? "unknown"}`; | ||
| console.error(`Error: Failed to clone/update marketplace: ${details}`); | ||
| process.exit(1); | ||
| } |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The runGitCommand function has a potential issue with the result.exitCode check. The exitCode can be null if the spawn call is interrupted or fails in certain ways. The current check on line 117 doesn't handle null explicitly, though line 121 does use a nullish coalescing operator. Consider adding an explicit check for null exitCode before comparing to 0, or handle it in the condition on line 117.
src/marketplace.ts
Outdated
| console.error( | ||
| `Error: Marketplace "${parsed.name}" already exists with a different source`, | ||
| ); |
Copilot
AI
Jan 16, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the addMarketplace function, when checking for an existing marketplace with a different source (lines 171-186), the check only looks at the repo field for GitHub sources. If a marketplace exists as a directory source and a user tries to add the same name as a GitHub source, it will be rejected. However, if the check fails to match, there's no cleanup of any partial state. Consider whether the error message could be more specific about what kind of conflict exists.
| console.error( | |
| `Error: Marketplace "${parsed.name}" already exists with a different source`, | |
| ); | |
| let conflictMessage = `Error: Marketplace "${parsed.name}" already exists with a different source.`; | |
| if (existing.source.source === "github") { | |
| const existingRepo = existing.source.repo ?? "<unknown>"; | |
| conflictMessage += ` It is currently registered as a GitHub marketplace pointing to "${existingRepo}",` | |
| + ` but you attempted to add it as "${parsed.repo}".`; | |
| } else if (existing.source.source === "directory") { | |
| const existingPath = existing.source.path ?? "<unknown path>"; | |
| conflictMessage += ` It is currently registered as a directory-based marketplace at "${existingPath}",` | |
| + ` so you must remove or rename it before adding a GitHub-based marketplace with the same name.`; | |
| } | |
| console.error(conflictMessage); |
- Add test for parsing GitHub URLs with .git suffix - Replace yargs conflicts with custom check() validation for name/--all - Add try-catch error handling to writeKnownMarketplaces - Handle null exitCode in runGitCommand - Add path sanitization to prevent directory traversal in marketplace names - Improve error message for marketplace conflicts with different sources
Make it easier to manage marketplaces and plugins from outside of claude