feat: support local plugin marketplace paths

Enable installing plugins from local directories in addition to remote
Git URLs. This allows users to use local plugin marketplaces within their
repository without requiring them to be hosted in a separate Git repo.

Example usage:
  plugin_marketplaces: "./my-local-marketplace"
  plugins: "my-plugin@my-local-marketplace"

Supported path formats:
- Relative paths: ./plugins, ../shared-plugins
- Absolute Unix paths: /home/user/plugins
- Absolute Windows paths: C:\Users\user\plugins

Fixes #664

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gor Grigoryan
2025-12-20 18:17:40 +04:00
parent b89827f8d1
commit 4a6660813c
2 changed files with 150 additions and 22 deletions

View File

@@ -8,26 +8,47 @@ const MARKETPLACE_URL_REGEX =
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/; /^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
/** /**
* Validates a marketplace URL for security issues * Checks if a marketplace input is a local path (not a URL)
* @param url - The marketplace URL to validate * @param input - The marketplace input to check
* @throws {Error} If the URL is invalid * @returns true if the input is a local path, false if it's a URL
*/ */
function validateMarketplaceUrl(url: string): void { function isLocalPath(input: string): boolean {
const normalized = url.trim(); // Local paths start with ./, ../, /, or a drive letter (Windows)
return (
input.startsWith("./") ||
input.startsWith("../") ||
input.startsWith("/") ||
/^[a-zA-Z]:[\\\/]/.test(input)
);
}
/**
* Validates a marketplace URL or local path
* @param input - The marketplace URL or local path to validate
* @throws {Error} If the input is invalid
*/
function validateMarketplaceInput(input: string): void {
const normalized = input.trim();
if (!normalized) { if (!normalized) {
throw new Error("Marketplace URL cannot be empty"); throw new Error("Marketplace URL or path cannot be empty");
} }
// Local paths are passed directly to Claude Code which handles them
if (isLocalPath(normalized)) {
return;
}
// Validate as URL
if (!MARKETPLACE_URL_REGEX.test(normalized)) { if (!MARKETPLACE_URL_REGEX.test(normalized)) {
throw new Error(`Invalid marketplace URL format: ${url}`); throw new Error(`Invalid marketplace URL format: ${input}`);
} }
// Additional check for valid URL structure // Additional check for valid URL structure
try { try {
new URL(normalized); new URL(normalized);
} catch { } catch {
throw new Error(`Invalid marketplace URL: ${url}`); throw new Error(`Invalid marketplace URL: ${input}`);
} }
} }
@@ -55,9 +76,9 @@ function validatePluginName(pluginName: string): void {
} }
/** /**
* Parse a newline-separated list of marketplace URLs and return an array of validated URLs * Parse a newline-separated list of marketplace URLs or local paths and return an array of validated entries
* @param marketplaces - Newline-separated list of marketplace Git URLs * @param marketplaces - Newline-separated list of marketplace Git URLs or local paths
* @returns Array of validated marketplace URLs (empty array if none provided) * @returns Array of validated marketplace URLs or paths (empty array if none provided)
*/ */
function parseMarketplaces(marketplaces?: string): string[] { function parseMarketplaces(marketplaces?: string): string[] {
const trimmed = marketplaces?.trim(); const trimmed = marketplaces?.trim();
@@ -66,14 +87,14 @@ function parseMarketplaces(marketplaces?: string): string[] {
return []; return [];
} }
// Split by newline and process each URL // Split by newline and process each entry
return trimmed return trimmed
.split("\n") .split("\n")
.map((url) => url.trim()) .map((entry) => entry.trim())
.filter((url) => { .filter((entry) => {
if (url.length === 0) return false; if (entry.length === 0) return false;
validateMarketplaceUrl(url); validateMarketplaceInput(entry);
return true; return true;
}); });
} }
@@ -163,26 +184,26 @@ async function installPlugin(
/** /**
* Adds a Claude Code plugin marketplace * Adds a Claude Code plugin marketplace
* @param claudeExecutable - Path to the Claude executable * @param claudeExecutable - Path to the Claude executable
* @param marketplaceUrl - The marketplace Git URL to add * @param marketplace - The marketplace Git URL or local path to add
* @returns Promise that resolves when the marketplace add command completes * @returns Promise that resolves when the marketplace add command completes
* @throws {Error} If the command fails to execute * @throws {Error} If the command fails to execute
*/ */
async function addMarketplace( async function addMarketplace(
claudeExecutable: string, claudeExecutable: string,
marketplaceUrl: string, marketplace: string,
): Promise<void> { ): Promise<void> {
console.log(`Adding marketplace: ${marketplaceUrl}`); console.log(`Adding marketplace: ${marketplace}`);
return executeClaudeCommand( return executeClaudeCommand(
claudeExecutable, claudeExecutable,
["plugin", "marketplace", "add", marketplaceUrl], ["plugin", "marketplace", "add", marketplace],
`Failed to add marketplace '${marketplaceUrl}'`, `Failed to add marketplace '${marketplace}'`,
); );
} }
/** /**
* Installs Claude Code plugins from a newline-separated list * Installs Claude Code plugins from a newline-separated list
* @param marketplacesInput - Newline-separated list of marketplace Git URLs * @param marketplacesInput - Newline-separated list of marketplace Git URLs or local paths
* @param pluginsInput - Newline-separated list of plugin names * @param pluginsInput - Newline-separated list of plugin names
* @param claudeExecutable - Path to the Claude executable (defaults to "claude") * @param claudeExecutable - Path to the Claude executable (defaults to "claude")
* @returns Promise that resolves when all plugins are installed * @returns Promise that resolves when all plugins are installed

View File

@@ -596,4 +596,111 @@ describe("installPlugins", () => {
{ stdio: "inherit" }, { stdio: "inherit" },
); );
}); });
// Local marketplace path tests
test("should accept local marketplace path with ./", async () => {
const spy = createMockSpawn();
await installPlugins("./my-local-marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./my-local-marketplace"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should accept local marketplace path with absolute Unix path", async () => {
const spy = createMockSpawn();
await installPlugins("/home/user/my-marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "/home/user/my-marketplace"],
{ stdio: "inherit" },
);
});
test("should accept local marketplace path with Windows absolute path", async () => {
const spy = createMockSpawn();
await installPlugins("C:\\Users\\user\\marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "C:\\Users\\user\\marketplace"],
{ stdio: "inherit" },
);
});
test("should accept mixed local and remote marketplaces", async () => {
const spy = createMockSpawn();
await installPlugins(
"./local-marketplace\nhttps://github.com/user/remote.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./local-marketplace"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/remote.git"],
{ stdio: "inherit" },
);
});
test("should accept local path with ../ (parent directory)", async () => {
const spy = createMockSpawn();
await installPlugins("../shared-plugins/marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "../shared-plugins/marketplace"],
{ stdio: "inherit" },
);
});
test("should accept local path with nested directories", async () => {
const spy = createMockSpawn();
await installPlugins("./plugins/my-org/my-marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./plugins/my-org/my-marketplace"],
{ stdio: "inherit" },
);
});
test("should accept local path with dots in directory name", async () => {
const spy = createMockSpawn();
await installPlugins("./my.plugin.marketplace", "test-plugin");
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "./my.plugin.marketplace"],
{ stdio: "inherit" },
);
});
}); });