mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user