diff --git a/action.yml b/action.yml index 627375e..a364372 100644 --- a/action.yml +++ b/action.yml @@ -102,7 +102,7 @@ inputs: required: false default: "" plugins: - description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')" + description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')" required: false default: "" plugin_marketplaces: diff --git a/base-action/action.yml b/base-action/action.yml index 91fa75a..db31a11 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -56,7 +56,7 @@ inputs: required: false default: "" plugins: - description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')" + description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')" required: false default: "" plugin_marketplaces: diff --git a/base-action/src/index.ts b/base-action/src/index.ts index 5a5036e..87a32d6 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -18,9 +18,9 @@ async function run() { // Install Claude Code plugins if specified await installPlugins( + process.env.INPUT_PLUGIN_MARKETPLACES, process.env.INPUT_PLUGINS, process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, - process.env.INPUT_PLUGIN_MARKETPLACES, ); const promptConfig = await preparePrompt({ diff --git a/base-action/src/install-plugins.ts b/base-action/src/install-plugins.ts index b3a9d80..e238bba 100644 --- a/base-action/src/install-plugins.ts +++ b/base-action/src/install-plugins.ts @@ -79,10 +79,13 @@ function parseMarketplaces(marketplaces?: string): string[] { } /** - * Parse a comma-separated list of plugin names and return an array of trimmed, non-empty plugin names + * Parse a newline-separated list of plugin names and return an array of trimmed, non-empty plugin names * Validates plugin names to prevent command injection and path traversal attacks * Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters) * Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots + * @param plugins - Newline-separated list of plugin names, or undefined/empty to return empty array + * @returns Array of validated plugin names (empty array if none provided) + * @throws {Error} If any plugin name fails validation */ function parsePlugins(plugins?: string): string[] { const trimmedPlugins = plugins?.trim(); @@ -91,9 +94,9 @@ function parsePlugins(plugins?: string): string[] { return []; } - // Split by comma and process each plugin + // Split by newline and process each plugin return trimmedPlugins - .split(",") + .split("\n") .map((p) => p.trim()) .filter((p) => { if (p.length === 0) return false; @@ -139,11 +142,17 @@ async function executeClaudeCommand( /** * Installs a single Claude Code plugin + * @param pluginName - The name of the plugin to install + * @param claudeExecutable - Path to the Claude executable + * @returns Promise that resolves when the plugin is installed successfully + * @throws {Error} If the plugin installation fails */ async function installPlugin( pluginName: string, claudeExecutable: string, ): Promise { + console.log(`Installing plugin: ${pluginName}`); + return executeClaudeCommand( claudeExecutable, ["plugin", "install", pluginName], @@ -172,25 +181,18 @@ async function addMarketplace( } /** - * Installs Claude Code plugins from a comma-separated list - * @param pluginsInput - Comma-separated list of plugin names, or undefined/empty to skip installation - * @param claudeExecutable - Path to the Claude executable (defaults to "claude") + * Installs Claude Code plugins from a newline-separated list * @param marketplacesInput - Newline-separated list of marketplace Git URLs + * @param pluginsInput - Newline-separated list of plugin names + * @param claudeExecutable - Path to the Claude executable (defaults to "claude") * @returns Promise that resolves when all plugins are installed * @throws {Error} If any plugin fails validation or installation (stops on first error) */ export async function installPlugins( - pluginsInput: string | undefined, - claudeExecutable?: string, marketplacesInput?: string, + pluginsInput?: string, + claudeExecutable?: string, ): Promise { - const plugins = parsePlugins(pluginsInput); - - if (plugins.length === 0) { - console.log("No plugins to install"); - return; - } - // Resolve executable path with explicit fallback const resolvedExecutable = claudeExecutable || "claude"; @@ -207,13 +209,14 @@ export async function installPlugins( console.log("No marketplaces specified, skipping marketplace setup"); } - console.log(`Installing ${plugins.length} plugin(s)...`); - - for (const plugin of plugins) { - console.log(`Installing plugin: ${plugin}`); - await installPlugin(plugin, resolvedExecutable); - console.log(`✓ Successfully installed: ${plugin}`); + const plugins = parsePlugins(pluginsInput); + if (plugins.length > 0) { + console.log(`Installing ${plugins.length} plugin(s)...`); + for (const plugin of plugins) { + await installPlugin(plugin, resolvedExecutable); + console.log(`✓ Successfully installed: ${plugin}`); + } + } else { + console.log("No plugins specified, skipping plugins installation"); } - - console.log("All plugins installed successfully"); } diff --git a/base-action/test/install-plugins.test.ts b/base-action/test/install-plugins.test.ts index d8676b8..53e8a5e 100644 --- a/base-action/test/install-plugins.test.ts +++ b/base-action/test/install-plugins.test.ts @@ -39,25 +39,25 @@ describe("installPlugins", () => { test("should not call spawn when no plugins are specified", async () => { const spy = createMockSpawn(); - await installPlugins(""); + await installPlugins(undefined, ""); expect(spy).not.toHaveBeenCalled(); }); test("should not call spawn when plugins is undefined", async () => { const spy = createMockSpawn(); - await installPlugins(undefined); + await installPlugins(undefined, undefined); expect(spy).not.toHaveBeenCalled(); }); test("should not call spawn when plugins is only whitespace", async () => { const spy = createMockSpawn(); - await installPlugins(" "); + await installPlugins(undefined, " "); expect(spy).not.toHaveBeenCalled(); }); test("should install a single plugin with default executable", async () => { const spy = createMockSpawn(); - await installPlugins("test-plugin"); + await installPlugins(undefined, "test-plugin"); expect(spy).toHaveBeenCalledTimes(1); // Only call: install plugin (no marketplace without explicit marketplace input) @@ -71,7 +71,7 @@ describe("installPlugins", () => { test("should install multiple plugins sequentially", async () => { const spy = createMockSpawn(); - await installPlugins("plugin1,plugin2,plugin3"); + await installPlugins(undefined, "plugin1\nplugin2\nplugin3"); expect(spy).toHaveBeenCalledTimes(3); // Install plugins (no marketplace without explicit marketplace input) @@ -97,7 +97,7 @@ describe("installPlugins", () => { test("should use custom claude executable path when provided", async () => { const spy = createMockSpawn(); - await installPlugins("test-plugin", "/custom/path/to/claude"); + await installPlugins(undefined, "test-plugin", "/custom/path/to/claude"); expect(spy).toHaveBeenCalledTimes(1); // Only call: install plugin (no marketplace without explicit marketplace input) @@ -111,7 +111,7 @@ describe("installPlugins", () => { test("should trim whitespace from plugin names before installation", async () => { const spy = createMockSpawn(); - await installPlugins(" plugin1 , plugin2 "); + await installPlugins(undefined, " plugin1 \n plugin2 "); expect(spy).toHaveBeenCalledTimes(2); // Install plugins (no marketplace without explicit marketplace input) @@ -131,7 +131,7 @@ describe("installPlugins", () => { test("should skip empty entries in plugin list", async () => { const spy = createMockSpawn(); - await installPlugins("plugin1,,plugin2"); + await installPlugins(undefined, "plugin1\n\nplugin2"); expect(spy).toHaveBeenCalledTimes(2); // Install plugins (no marketplace without explicit marketplace input) @@ -152,7 +152,7 @@ describe("installPlugins", () => { test("should handle plugin installation error and throw", async () => { createMockSpawn(1, false); // Exit code 1 - await expect(installPlugins("failing-plugin")).rejects.toThrow( + await expect(installPlugins(undefined, "failing-plugin")).rejects.toThrow( "Failed to install plugin 'failing-plugin' (exit code: 1)", ); }); @@ -160,7 +160,9 @@ describe("installPlugins", () => { test("should handle null exit code (process terminated by signal)", async () => { createMockSpawn(null, false); // Exit code null (terminated by signal) - await expect(installPlugins("terminated-plugin")).rejects.toThrow( + await expect( + installPlugins(undefined, "terminated-plugin"), + ).rejects.toThrow( "Failed to install plugin 'terminated-plugin': process terminated by signal", ); }); @@ -168,9 +170,9 @@ describe("installPlugins", () => { test("should stop installation on first error", async () => { const spy = createMockSpawn(1, false); // Exit code 1 - await expect(installPlugins("plugin1,plugin2,plugin3")).rejects.toThrow( - "Failed to install plugin 'plugin1' (exit code: 1)", - ); + await expect( + installPlugins(undefined, "plugin1\nplugin2\nplugin3"), + ).rejects.toThrow("Failed to install plugin 'plugin1' (exit code: 1)"); // Should only try to install first plugin before failing expect(spy).toHaveBeenCalledTimes(1); @@ -178,7 +180,7 @@ describe("installPlugins", () => { test("should handle plugins with special characters in names", async () => { const spy = createMockSpawn(); - await installPlugins("org/plugin-name,@scope/plugin"); + await installPlugins(undefined, "org/plugin-name\n@scope/plugin"); expect(spy).toHaveBeenCalledTimes(2); // Install plugins (no marketplace without explicit marketplace input) @@ -199,14 +201,18 @@ describe("installPlugins", () => { test("should handle spawn errors", async () => { createMockSpawn(0, true); // Trigger error event - await expect(installPlugins("test-plugin")).rejects.toThrow( + await expect(installPlugins(undefined, "test-plugin")).rejects.toThrow( "Failed to install plugin 'test-plugin': spawn error", ); }); test("should install plugins with custom executable and multiple plugins", async () => { const spy = createMockSpawn(); - await installPlugins("plugin-a,plugin-b", "/usr/local/bin/claude-custom"); + await installPlugins( + undefined, + "plugin-a\nplugin-b", + "/usr/local/bin/claude-custom", + ); expect(spy).toHaveBeenCalledTimes(2); // Install plugins (no marketplace without explicit marketplace input) @@ -228,9 +234,9 @@ describe("installPlugins", () => { const spy = createMockSpawn(); // Should throw due to invalid characters (semicolon and spaces) - await expect(installPlugins("plugin-name; rm -rf /")).rejects.toThrow( - "Invalid plugin name format", - ); + await expect( + installPlugins(undefined, "plugin-name; rm -rf /"), + ).rejects.toThrow("Invalid plugin name format"); // Mock should never be called because validation fails first expect(spy).not.toHaveBeenCalled(); @@ -239,9 +245,9 @@ describe("installPlugins", () => { test("should reject plugin names with path traversal using ../", async () => { const spy = createMockSpawn(); - await expect(installPlugins("../../../malicious-plugin")).rejects.toThrow( - "Invalid plugin name format", - ); + await expect( + installPlugins(undefined, "../../../malicious-plugin"), + ).rejects.toThrow("Invalid plugin name format"); expect(spy).not.toHaveBeenCalled(); }); @@ -249,9 +255,9 @@ describe("installPlugins", () => { test("should reject plugin names with path traversal using ./", async () => { const spy = createMockSpawn(); - await expect(installPlugins("./../../@scope/package")).rejects.toThrow( - "Invalid plugin name format", - ); + await expect( + installPlugins(undefined, "./../../@scope/package"), + ).rejects.toThrow("Invalid plugin name format"); expect(spy).not.toHaveBeenCalled(); }); @@ -259,7 +265,7 @@ describe("installPlugins", () => { test("should reject plugin names with consecutive dots", async () => { const spy = createMockSpawn(); - await expect(installPlugins(".../.../package")).rejects.toThrow( + await expect(installPlugins(undefined, ".../.../package")).rejects.toThrow( "Invalid plugin name format", ); @@ -269,7 +275,7 @@ describe("installPlugins", () => { test("should reject plugin names with hidden path traversal", async () => { const spy = createMockSpawn(); - await expect(installPlugins("package/../other")).rejects.toThrow( + await expect(installPlugins(undefined, "package/../other")).rejects.toThrow( "Invalid plugin name format", ); @@ -278,7 +284,7 @@ describe("installPlugins", () => { test("should accept plugin names with single dots in version numbers", async () => { const spy = createMockSpawn(); - await installPlugins("plugin-v1.0.2"); + await installPlugins(undefined, "plugin-v1.0.2"); expect(spy).toHaveBeenCalledTimes(1); // Only call: install plugin (no marketplace without explicit marketplace input) @@ -292,7 +298,7 @@ describe("installPlugins", () => { test("should accept plugin names with multiple dots in semantic versions", async () => { const spy = createMockSpawn(); - await installPlugins("@scope/plugin-v1.0.0-beta.1"); + await installPlugins(undefined, "@scope/plugin-v1.0.0-beta.1"); expect(spy).toHaveBeenCalledTimes(1); // Only call: install plugin (no marketplace without explicit marketplace input) @@ -308,7 +314,7 @@ describe("installPlugins", () => { const spy = createMockSpawn(); // Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F) - await expect(installPlugins("../malicious")).rejects.toThrow( + await expect(installPlugins(undefined, "../malicious")).rejects.toThrow( "Invalid plugin name format", ); @@ -318,7 +324,7 @@ describe("installPlugins", () => { test("should reject path traversal at end of path", async () => { const spy = createMockSpawn(); - await expect(installPlugins("package/..")).rejects.toThrow( + await expect(installPlugins(undefined, "package/..")).rejects.toThrow( "Invalid plugin name format", ); @@ -328,7 +334,7 @@ describe("installPlugins", () => { test("should reject single dot directory reference", async () => { const spy = createMockSpawn(); - await expect(installPlugins("package/.")).rejects.toThrow( + await expect(installPlugins(undefined, "package/.")).rejects.toThrow( "Invalid plugin name format", ); @@ -338,10 +344,256 @@ describe("installPlugins", () => { test("should reject path traversal in middle of path", async () => { const spy = createMockSpawn(); - await expect(installPlugins("package/../other")).rejects.toThrow( + await expect(installPlugins(undefined, "package/../other")).rejects.toThrow( "Invalid plugin name format", ); expect(spy).not.toHaveBeenCalled(); }); + + // Marketplace functionality tests + test("should add a single marketplace before installing plugins", async () => { + const spy = createMockSpawn(); + await installPlugins( + "https://github.com/user/marketplace.git", + "test-plugin", + ); + + expect(spy).toHaveBeenCalledTimes(2); + // First call: add marketplace + expect(spy).toHaveBeenNthCalledWith( + 1, + "claude", + [ + "plugin", + "marketplace", + "add", + "https://github.com/user/marketplace.git", + ], + { stdio: "inherit" }, + ); + // Second call: install plugin + expect(spy).toHaveBeenNthCalledWith( + 2, + "claude", + ["plugin", "install", "test-plugin"], + { stdio: "inherit" }, + ); + }); + + test("should add multiple marketplaces with newline separation", async () => { + const spy = createMockSpawn(); + await installPlugins( + "https://github.com/user/m1.git\nhttps://github.com/user/m2.git", + "test-plugin", + ); + + expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces + 1 plugin + // First two calls: add marketplaces + expect(spy).toHaveBeenNthCalledWith( + 1, + "claude", + ["plugin", "marketplace", "add", "https://github.com/user/m1.git"], + { stdio: "inherit" }, + ); + expect(spy).toHaveBeenNthCalledWith( + 2, + "claude", + ["plugin", "marketplace", "add", "https://github.com/user/m2.git"], + { stdio: "inherit" }, + ); + // Third call: install plugin + expect(spy).toHaveBeenNthCalledWith( + 3, + "claude", + ["plugin", "install", "test-plugin"], + { stdio: "inherit" }, + ); + }); + + test("should add marketplaces before installing multiple plugins", async () => { + const spy = createMockSpawn(); + await installPlugins( + "https://github.com/user/marketplace.git", + "plugin1\nplugin2", + ); + + expect(spy).toHaveBeenCalledTimes(3); // 1 marketplace + 2 plugins + // First call: add marketplace + expect(spy).toHaveBeenNthCalledWith( + 1, + "claude", + [ + "plugin", + "marketplace", + "add", + "https://github.com/user/marketplace.git", + ], + { stdio: "inherit" }, + ); + // Next calls: install plugins + expect(spy).toHaveBeenNthCalledWith( + 2, + "claude", + ["plugin", "install", "plugin1"], + { stdio: "inherit" }, + ); + expect(spy).toHaveBeenNthCalledWith( + 3, + "claude", + ["plugin", "install", "plugin2"], + { stdio: "inherit" }, + ); + }); + + test("should handle only marketplaces without plugins", async () => { + const spy = createMockSpawn(); + await installPlugins("https://github.com/user/marketplace.git", undefined); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenNthCalledWith( + 1, + "claude", + [ + "plugin", + "marketplace", + "add", + "https://github.com/user/marketplace.git", + ], + { stdio: "inherit" }, + ); + }); + + test("should skip empty marketplace entries", async () => { + const spy = createMockSpawn(); + await installPlugins( + "https://github.com/user/m1.git\n\nhttps://github.com/user/m2.git", + "test-plugin", + ); + + expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces (skip empty) + 1 plugin + }); + + test("should trim whitespace from marketplace URLs", async () => { + const spy = createMockSpawn(); + await installPlugins( + " https://github.com/user/marketplace.git \n https://github.com/user/m2.git ", + "test-plugin", + ); + + expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenNthCalledWith( + 1, + "claude", + [ + "plugin", + "marketplace", + "add", + "https://github.com/user/marketplace.git", + ], + { stdio: "inherit" }, + ); + expect(spy).toHaveBeenNthCalledWith( + 2, + "claude", + ["plugin", "marketplace", "add", "https://github.com/user/m2.git"], + { stdio: "inherit" }, + ); + }); + + test("should reject invalid marketplace URL format", async () => { + const spy = createMockSpawn(); + + await expect( + installPlugins("not-a-valid-url", "test-plugin"), + ).rejects.toThrow("Invalid marketplace URL format"); + + expect(spy).not.toHaveBeenCalled(); + }); + + test("should reject marketplace URL without .git extension", async () => { + const spy = createMockSpawn(); + + await expect( + installPlugins("https://github.com/user/marketplace", "test-plugin"), + ).rejects.toThrow("Invalid marketplace URL format"); + + expect(spy).not.toHaveBeenCalled(); + }); + + test("should reject marketplace URL with non-https protocol", async () => { + const spy = createMockSpawn(); + + await expect( + installPlugins("http://github.com/user/marketplace.git", "test-plugin"), + ).rejects.toThrow("Invalid marketplace URL format"); + + expect(spy).not.toHaveBeenCalled(); + }); + + test("should skip whitespace-only marketplace input", async () => { + const spy = createMockSpawn(); + await installPlugins(" ", "test-plugin"); + + // Should skip marketplaces and only install plugin + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenNthCalledWith( + 1, + "claude", + ["plugin", "install", "test-plugin"], + { stdio: "inherit" }, + ); + }); + + test("should handle marketplace addition error", async () => { + createMockSpawn(1, false); // Exit code 1 + + await expect( + installPlugins("https://github.com/user/marketplace.git", "test-plugin"), + ).rejects.toThrow( + "Failed to add marketplace 'https://github.com/user/marketplace.git' (exit code: 1)", + ); + }); + + test("should stop if marketplace addition fails before installing plugins", async () => { + const spy = createMockSpawn(1, false); // Exit code 1 + + await expect( + installPlugins( + "https://github.com/user/marketplace.git", + "plugin1\nplugin2", + ), + ).rejects.toThrow("Failed to add marketplace"); + + // Should only try to add marketplace, not install any plugins + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("should use custom executable for marketplace operations", async () => { + const spy = createMockSpawn(); + await installPlugins( + "https://github.com/user/marketplace.git", + "test-plugin", + "/custom/path/to/claude", + ); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenNthCalledWith( + 1, + "/custom/path/to/claude", + [ + "plugin", + "marketplace", + "add", + "https://github.com/user/marketplace.git", + ], + { stdio: "inherit" }, + ); + expect(spy).toHaveBeenNthCalledWith( + 2, + "/custom/path/to/claude", + ["plugin", "install", "test-plugin"], + { stdio: "inherit" }, + ); + }); }); diff --git a/docs/usage.md b/docs/usage.md index 829c674..818b0c8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -32,8 +32,10 @@ jobs: # --max-turns 10 # --model claude-4-0-sonnet-20250805 + # Optional: add custom plugin marketplaces + # plugin_marketplaces: "https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git" # Optional: install Claude Code plugins - # plugins: "plugin1,plugin2,plugin3" + # plugins: "code-review@claude-code-plugins\nfeature-dev@claude-code-plugins" # Optional: add custom trigger phrase (default: @claude) # trigger_phrase: "/claude" @@ -76,7 +78,8 @@ jobs: | `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" | | `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" | | `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" | -| `plugins` | Comma-separated list of Claude Code plugin names to install (e.g., `plugin1,plugin2,plugin3`). Plugins are installed before Claude Code execution | No | "" | +| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" | +| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" | ### Deprecated Inputs