diff --git a/action.yml b/action.yml index 0bbe537e8..05fb25a18 100644 --- a/action.yml +++ b/action.yml @@ -90,6 +90,10 @@ inputs: description: "Additional arguments to pass directly to Claude CLI" required: false default: "" + max_turns: + description: "Maximum number of turns for the Claude session (default: 10 if not set)" + required: false + default: "" additional_permissions: description: "Additional GitHub permissions to request (e.g., 'actions: read')" required: false @@ -266,6 +270,7 @@ runs: INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }} INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} + INPUT_MAX_TURNS: ${{ inputs.max_turns }} DISPLAY_REPORT: ${{ inputs.display_report }} INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }} diff --git a/base-action/action.yml b/base-action/action.yml index 10ed8c8e3..df360230e 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -63,6 +63,10 @@ inputs: description: "Show full JSON output from Claude Code. WARNING: This outputs ALL Claude messages including tool execution results which may contain secrets, API keys, or other sensitive information. These logs are publicly visible in GitHub Actions. Only enable for debugging in non-sensitive environments." required: false default: "false" + max_turns: + description: "Maximum number of turns for the Claude session (default: 10 if not set)" + required: false + default: "" plugins: 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 @@ -169,6 +173,7 @@ runs: INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }} INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} + INPUT_MAX_TURNS: ${{ inputs.max_turns }} INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }} diff --git a/base-action/src/install-plugins.ts b/base-action/src/install-plugins.ts index 0eb12e744..4b404d439 100644 --- a/base-action/src/install-plugins.ts +++ b/base-action/src/install-plugins.ts @@ -194,11 +194,61 @@ async function addMarketplace( ): Promise { console.log(`Adding marketplace: ${marketplace}`); - return executeClaudeCommand( - claudeExecutable, - ["plugin", "marketplace", "add", marketplace], - `Failed to add marketplace '${marketplace}'`, - ); + return new Promise((resolve, reject) => { + const outputChunks: Buffer[] = []; + const childProcess: ChildProcess = spawn( + claudeExecutable, + ["plugin", "marketplace", "add", marketplace], + { stdio: ["inherit", "pipe", "pipe"] }, + ); + + // Mirror output to the parent process so it remains visible in logs. + childProcess.stdout?.on("data", (chunk: Buffer) => { + outputChunks.push(chunk); + process.stdout.write(chunk); + }); + childProcess.stderr?.on("data", (chunk: Buffer) => { + outputChunks.push(chunk); + process.stderr.write(chunk); + }); + + childProcess.on("close", (code: number | null) => { + if (code === 0) { + resolve(); + return; + } + // Non-ephemeral runners retain ~/.claude state across runs. Treat + // "already installed" as success so the action is idempotent on + // persistent runners without requiring a manual cleanup step. + const output = Buffer.concat(outputChunks).toString(); + if (output.includes("already installed")) { + console.log( + `Marketplace '${marketplace}' is already installed, skipping`, + ); + resolve(); + return; + } + if (code === null) { + reject( + new Error( + `Failed to add marketplace '${marketplace}': process terminated by signal`, + ), + ); + } else { + reject( + new Error( + `Failed to add marketplace '${marketplace}' (exit code: ${code})`, + ), + ); + } + }); + + childProcess.on("error", (err: Error) => { + reject( + new Error(`Failed to add marketplace '${marketplace}': ${err.message}`), + ); + }); + }); } /** diff --git a/base-action/test/install-plugins.test.ts b/base-action/test/install-plugins.test.ts index 7b0ab28ba..e4095a2f7 100644 --- a/base-action/test/install-plugins.test.ts +++ b/base-action/test/install-plugins.test.ts @@ -17,14 +17,25 @@ describe("installPlugins", () => { function createMockSpawn( exitCode: number | null = 0, shouldError: boolean = false, + output: string = "", ) { + const mockStream = { + on: mock((event: string, handler: Function) => { + if (event === "data" && output) { + setTimeout(() => handler(Buffer.from(output)), 0); + } + return mockStream; + }), + }; + const mockProcess = { + stdout: mockStream, + stderr: mockStream, on: mock((event: string, handler: Function) => { if (event === "close" && !shouldError) { - // Simulate successful close - setTimeout(() => handler(exitCode), 0); + // Delay past any data events so output is captured before close fires. + setTimeout(() => handler(exitCode), 10); } else if (event === "error" && shouldError) { - // Simulate error setTimeout(() => handler(new Error("spawn error")), 0); } return mockProcess; @@ -370,7 +381,7 @@ describe("installPlugins", () => { "add", "https://github.com/user/marketplace.git", ], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); // Second call: install plugin expect(spy).toHaveBeenNthCalledWith( @@ -394,13 +405,13 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "https://github.com/user/m1.git"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); expect(spy).toHaveBeenNthCalledWith( 2, "claude", ["plugin", "marketplace", "add", "https://github.com/user/m2.git"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); // Third call: install plugin expect(spy).toHaveBeenNthCalledWith( @@ -429,7 +440,7 @@ describe("installPlugins", () => { "add", "https://github.com/user/marketplace.git", ], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); // Next calls: install plugins expect(spy).toHaveBeenNthCalledWith( @@ -460,7 +471,7 @@ describe("installPlugins", () => { "add", "https://github.com/user/marketplace.git", ], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); }); @@ -491,13 +502,13 @@ describe("installPlugins", () => { "add", "https://github.com/user/marketplace.git", ], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); expect(spy).toHaveBeenNthCalledWith( 2, "claude", ["plugin", "marketplace", "add", "https://github.com/user/m2.git"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); }); @@ -587,7 +598,7 @@ describe("installPlugins", () => { "add", "https://github.com/user/marketplace.git", ], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); expect(spy).toHaveBeenNthCalledWith( 2, @@ -607,7 +618,7 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "./my-local-marketplace"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); expect(spy).toHaveBeenNthCalledWith( 2, @@ -626,7 +637,7 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "/home/user/my-marketplace"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); }); @@ -639,7 +650,7 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "C:\\Users\\user\\marketplace"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); }); @@ -655,13 +666,13 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "./local-marketplace"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); expect(spy).toHaveBeenNthCalledWith( 2, "claude", ["plugin", "marketplace", "add", "https://github.com/user/remote.git"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); }); @@ -674,7 +685,7 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "../shared-plugins/marketplace"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); }); @@ -687,7 +698,7 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "./plugins/my-org/my-marketplace"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, ); }); @@ -700,7 +711,33 @@ describe("installPlugins", () => { 1, "claude", ["plugin", "marketplace", "add", "./my.plugin.marketplace"], - { stdio: "inherit" }, + { stdio: ["inherit", "pipe", "pipe"] }, + ); + }); + + test("should treat 'already installed' marketplace as success (idempotent on persistent runners)", async () => { + // Simulate the CLI exiting 1 with an "already installed" message — + // the output text is what matters, not which stream it arrives on. + const spy = createMockSpawn( + 1, + false, + "Marketplace 'claude-code-plugins' is already installed.", + ); + await expect( + installPlugins( + "https://github.com/anthropics/claude-code.git", + undefined, + ), + ).resolves.toBeUndefined(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("should still throw for non-'already-installed' marketplace failures", async () => { + createMockSpawn(1, false, "Network error: could not reach remote"); + await expect( + installPlugins("https://github.com/user/marketplace.git", undefined), + ).rejects.toThrow( + "Failed to add marketplace 'https://github.com/user/marketplace.git' (exit code: 1)", ); }); }); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 2ab42f1a4..f2e36a77d 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -272,6 +272,7 @@ async function run() { pathToClaudeCodeExecutable: process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT, + maxTurns: process.env.INPUT_MAX_TURNS, }); claudeSuccess = claudeResult.conclusion === "success";