From f5f27d47168d911561387f1e01b40cb6cdc35562 Mon Sep 17 00:00:00 2001 From: Wanghong Yuan MacBook Date: Mon, 20 Oct 2025 22:32:57 -0700 Subject: [PATCH] Add plugins input to GitHub Action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for installing Claude Code plugins via a new `plugins` input parameter. Changes: - Added `plugins` input to action.yml (comma-separated list) - Created `install-plugins.ts` with plugin installation logic - Added comprehensive tests in `install-plugins.test.ts` - Updated base-action index.ts to call plugin installation - Plugins are installed after settings setup but before Claude execution Usage example: ```yaml - uses: anthropic-ai/claude-code-action@main with: plugins: "feature-dev,test-coverage-reviewer" ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- action.yml | 5 ++ base-action/src/index.ts | 7 ++ base-action/src/install-plugins.ts | 80 ++++++++++++++++++++++ base-action/test/install-plugins.test.ts | 84 ++++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 base-action/src/install-plugins.ts create mode 100644 base-action/test/install-plugins.test.ts diff --git a/action.yml b/action.yml index 50e0013..896cc00 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,10 @@ inputs: description: "Claude Code settings as JSON string or path to settings JSON file" required: false default: "" + plugins: + description: "Comma-separated list of Claude Code plugins to install (e.g., 'plugin-name1,plugin-name2')" + required: false + default: "" # Auth configuration anthropic_api_key: @@ -208,6 +212,7 @@ runs: CLAUDE_CODE_ACTION: "1" INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt INPUT_SETTINGS: ${{ inputs.settings }} + INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }} diff --git a/base-action/src/index.ts b/base-action/src/index.ts index bd61825..183931c 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt"; import { runClaude } from "./run-claude"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { validateEnvironmentVariables } from "./validate-env"; +import { installPlugins } from "./install-plugins"; async function run() { try { @@ -15,6 +16,12 @@ async function run() { undefined, // homeDir ); + // Install plugins if specified + await installPlugins( + process.env.INPUT_PLUGINS, + process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE || "claude", + ); + const promptConfig = await preparePrompt({ prompt: process.env.INPUT_PROMPT || "", promptFile: process.env.INPUT_PROMPT_FILE || "", diff --git a/base-action/src/install-plugins.ts b/base-action/src/install-plugins.ts new file mode 100644 index 0000000..59162a1 --- /dev/null +++ b/base-action/src/install-plugins.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun + +import { spawn } from "child_process"; + +// Declare console as global for TypeScript +declare const console: { + log: (message: string) => void; + error: (message: string) => void; +}; + +/** + * Parses a comma-separated list of plugin names and returns an array of trimmed plugin names + */ +export function parsePlugins(pluginsInput: string | undefined): string[] { + if (!pluginsInput || pluginsInput.trim() === "") { + return []; + } + + return pluginsInput + .split(",") + .map((plugin) => plugin.trim()) + .filter((plugin) => plugin.length > 0); +} + +/** + * Installs a single Claude Code plugin + */ +export async function installPlugin( + pluginName: string, + claudeExecutable: string = "claude", +): Promise { + return new Promise((resolve, reject) => { + const process = spawn(claudeExecutable, ["plugin", "install", pluginName], { + stdio: "inherit", + }); + + process.on("close", (code: number | null) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `Failed to install plugin '${pluginName}' (exit code: ${code})`, + ), + ); + } + }); + + process.on("error", (err: Error) => { + reject( + new Error(`Failed to install plugin '${pluginName}': ${err.message}`), + ); + }); + }); +} + +/** + * Installs Claude Code plugins from a comma-separated list + */ +export async function installPlugins( + pluginsInput: string | undefined, + claudeExecutable: string = "claude", +): Promise { + const plugins = parsePlugins(pluginsInput); + + if (plugins.length === 0) { + console.log("No plugins to install"); + return; + } + + console.log(`Installing ${plugins.length} plugin(s)...`); + + for (const plugin of plugins) { + console.log(`Installing plugin: ${plugin}`); + await installPlugin(plugin, claudeExecutable); + console.log(`✓ Successfully installed: ${plugin}`); + } + + console.log("All plugins installed successfully"); +} diff --git a/base-action/test/install-plugins.test.ts b/base-action/test/install-plugins.test.ts new file mode 100644 index 0000000..c31ed7f --- /dev/null +++ b/base-action/test/install-plugins.test.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env bun + +import { describe, test, expect } from "bun:test"; +import { parsePlugins } from "../src/install-plugins"; + +describe("parsePlugins", () => { + test("should return empty array for undefined input", () => { + expect(parsePlugins(undefined)).toEqual([]); + }); + + test("should return empty array for empty string", () => { + expect(parsePlugins("")).toEqual([]); + }); + + test("should return empty array for whitespace-only string", () => { + expect(parsePlugins(" \n\t ")).toEqual([]); + }); + + test("should parse single plugin", () => { + expect(parsePlugins("feature-dev")).toEqual(["feature-dev"]); + }); + + test("should parse multiple plugins", () => { + expect(parsePlugins("feature-dev,test-coverage-reviewer")).toEqual([ + "feature-dev", + "test-coverage-reviewer", + ]); + }); + + test("should trim whitespace around plugin names", () => { + expect(parsePlugins(" feature-dev , test-coverage-reviewer ")).toEqual([ + "feature-dev", + "test-coverage-reviewer", + ]); + }); + + test("should handle spaces between commas", () => { + expect( + parsePlugins( + "feature-dev, test-coverage-reviewer, code-quality-reviewer", + ), + ).toEqual([ + "feature-dev", + "test-coverage-reviewer", + "code-quality-reviewer", + ]); + }); + + test("should filter out empty values from consecutive commas", () => { + expect(parsePlugins("feature-dev,,test-coverage-reviewer")).toEqual([ + "feature-dev", + "test-coverage-reviewer", + ]); + }); + + test("should handle trailing comma", () => { + expect(parsePlugins("feature-dev,test-coverage-reviewer,")).toEqual([ + "feature-dev", + "test-coverage-reviewer", + ]); + }); + + test("should handle leading comma", () => { + expect(parsePlugins(",feature-dev,test-coverage-reviewer")).toEqual([ + "feature-dev", + "test-coverage-reviewer", + ]); + }); + + test("should handle plugins with special characters", () => { + expect(parsePlugins("@scope/plugin-name,plugin-name-2")).toEqual([ + "@scope/plugin-name", + "plugin-name-2", + ]); + }); + + test("should handle complex whitespace patterns", () => { + expect( + parsePlugins( + "\n feature-dev \n,\t test-coverage-reviewer\t, code-quality \n", + ), + ).toEqual(["feature-dev", "test-coverage-reviewer", "code-quality"]); + }); +});