Skip to content

Conversation

@scaryrawr
Copy link
Owner

Make it easier to manage marketplaces and plugins from outside of claude

scaryrawr and others added 6 commits January 15, 2026 15:04
- 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]>
Copilot AI review requested due to automatic review settings January 16, 2026 03:12
Copy link

Copilot AI left a 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.

Comment on lines +153 to +183
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");
});

Copy link

Copilot AI Jan 16, 2026

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.

Copilot uses AI. Check for mistakes.
src/cli.ts Outdated
Comment on lines 154 to 160
conflicts: "all",
})
.option("all", {
type: "boolean",
alias: "a",
description: "Update all git-based marketplaces",
default: false,
Copy link

Copilot AI Jan 16, 2026

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +77 to +88
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 };
}
Copy link

Copilot AI Jan 16, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 70 to 71
mkdirSync(dirname(path), { recursive: true });
await Bun.write(path, JSON.stringify(data, null, 2));
Copy link

Copilot AI Jan 16, 2026

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.

Suggested change
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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +101
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;
}
Copy link

Copilot AI Jan 16, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +188 to +191
const marketplacesRoot = getMarketplacesRoot(paths);
mkdirSync(marketplacesRoot, { recursive: true });
const installLocation = join(marketplacesRoot, parsed.name);
const repoUrl = `https://github.com/${parsed.repo}.git`;
Copy link

Copilot AI Jan 16, 2026

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.).

Copilot uses AI. Check for mistakes.
Comment on lines +259 to +261
for (const [name] of githubMarketplaces) {
await updateMarketplace(name, paths);
}
Copy link

Copilot AI Jan 16, 2026

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).

Copilot uses AI. Check for mistakes.
Comment on lines 117 to 124
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);
}
Copy link

Copilot AI Jan 16, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 182 to 184
console.error(
`Error: Marketplace "${parsed.name}" already exists with a different source`,
);
Copy link

Copilot AI Jan 16, 2026

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
- 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
@scaryrawr scaryrawr merged commit 24f70c8 into main Jan 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants