mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
2 Commits
ashwin/inl
...
add-plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23e406ca24 | ||
|
|
f5f27d4716 |
13
action.yml
13
action.yml
@@ -41,6 +41,10 @@ inputs:
|
|||||||
description: "Claude Code settings as JSON string or path to settings JSON file"
|
description: "Claude Code settings as JSON string or path to settings JSON file"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
plugins:
|
||||||
|
description: "Comma-separated list of Claude Code plugins to install (e.g., 'plugin-name1,plugin-name2')"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
# Auth configuration
|
# Auth configuration
|
||||||
anthropic_api_key:
|
anthropic_api_key:
|
||||||
@@ -101,10 +105,6 @@ inputs:
|
|||||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
plugins:
|
|
||||||
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -167,7 +167,6 @@ runs:
|
|||||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||||
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||||
PLUGINS: ${{ inputs.plugins }}
|
|
||||||
|
|
||||||
- name: Install Base Action Dependencies
|
- name: Install Base Action Dependencies
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
@@ -182,7 +181,7 @@ runs:
|
|||||||
# Install Claude Code if no custom executable is provided
|
# Install Claude Code if no custom executable is provided
|
||||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||||
echo "Installing Claude Code..."
|
echo "Installing Claude Code..."
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.27
|
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.24
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
else
|
else
|
||||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||||
@@ -213,12 +212,12 @@ runs:
|
|||||||
CLAUDE_CODE_ACTION: "1"
|
CLAUDE_CODE_ACTION: "1"
|
||||||
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||||
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
|
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
|
||||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||||
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
||||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ inputs:
|
|||||||
description: "Claude Code settings as JSON string or path to settings JSON file"
|
description: "Claude Code settings as JSON string or path to settings JSON file"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
plugins:
|
||||||
|
description: "Comma-separated list of Claude Code plugins to install (e.g., 'plugin-name1,plugin-name2')"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
# Action settings
|
# Action settings
|
||||||
claude_args:
|
claude_args:
|
||||||
@@ -55,10 +59,6 @@ inputs:
|
|||||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
plugins:
|
|
||||||
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
conclusion:
|
conclusion:
|
||||||
@@ -103,7 +103,7 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||||
echo "Installing Claude Code..."
|
echo "Installing Claude Code..."
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.27
|
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.24
|
||||||
else
|
else
|
||||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
@@ -127,10 +127,10 @@ runs:
|
|||||||
INPUT_PROMPT: ${{ inputs.prompt }}
|
INPUT_PROMPT: ${{ inputs.prompt }}
|
||||||
INPUT_PROMPT_FILE: ${{ inputs.prompt_file }}
|
INPUT_PROMPT_FILE: ${{ inputs.prompt_file }}
|
||||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||||
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ async function run() {
|
|||||||
undefined, // homeDir
|
undefined, // homeDir
|
||||||
);
|
);
|
||||||
|
|
||||||
// Install Claude Code plugins if specified
|
// Install plugins if specified
|
||||||
await installPlugins(
|
await installPlugins(
|
||||||
process.env.INPUT_PLUGINS,
|
process.env.INPUT_PLUGINS,
|
||||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE || "claude",
|
||||||
);
|
);
|
||||||
|
|
||||||
const promptConfig = await preparePrompt({
|
const promptConfig = await preparePrompt({
|
||||||
|
|||||||
@@ -1,134 +1,65 @@
|
|||||||
import { spawn, ChildProcess } from "child_process";
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/;
|
import { spawn } from "child_process";
|
||||||
const MAX_PLUGIN_NAME_LENGTH = 512;
|
|
||||||
const CLAUDE_CODE_MARKETPLACE_URL =
|
// Declare console as global for TypeScript
|
||||||
"https://github.com/anthropics/claude-code.git";
|
declare const console: {
|
||||||
const PATH_TRAVERSAL_REGEX =
|
log: (message: string) => void;
|
||||||
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/;
|
error: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a plugin name for security issues
|
* Parses a comma-separated list of plugin names and returns an array of trimmed plugin names
|
||||||
* @param pluginName - The plugin name to validate
|
|
||||||
* @throws {Error} If the plugin name is invalid
|
|
||||||
*/
|
*/
|
||||||
function validatePluginName(pluginName: string): void {
|
export function parsePlugins(pluginsInput: string | undefined): string[] {
|
||||||
// Normalize Unicode to prevent homoglyph attacks (e.g., fullwidth dots, Unicode slashes)
|
if (!pluginsInput || pluginsInput.trim() === "") {
|
||||||
const normalized = pluginName.normalize("NFC");
|
|
||||||
|
|
||||||
if (normalized.length > MAX_PLUGIN_NAME_LENGTH) {
|
|
||||||
throw new Error(`Plugin name too long: ${normalized.substring(0, 50)}...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!PLUGIN_NAME_REGEX.test(normalized)) {
|
|
||||||
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent path traversal attacks with single efficient regex check
|
|
||||||
if (PATH_TRAVERSAL_REGEX.test(normalized)) {
|
|
||||||
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a comma-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
|
|
||||||
*/
|
|
||||||
function parsePlugins(plugins?: string): string[] {
|
|
||||||
const trimmedPlugins = plugins?.trim();
|
|
||||||
|
|
||||||
if (!trimmedPlugins) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by comma and process each plugin
|
return pluginsInput
|
||||||
return trimmedPlugins
|
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((p) => p.trim())
|
.map((plugin) => plugin.trim())
|
||||||
.filter((p) => {
|
.filter((plugin) => plugin.length > 0);
|
||||||
if (p.length === 0) return false;
|
|
||||||
|
|
||||||
validatePluginName(p);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes a Claude Code CLI command with proper error handling
|
|
||||||
* @param claudeExecutable - Path to the Claude executable
|
|
||||||
* @param args - Command arguments to pass to the executable
|
|
||||||
* @param errorContext - Context string for error messages (e.g., "Failed to install plugin 'foo'")
|
|
||||||
* @returns Promise that resolves when the command completes successfully
|
|
||||||
* @throws {Error} If the command fails to execute
|
|
||||||
*/
|
|
||||||
async function executeClaudeCommand(
|
|
||||||
claudeExecutable: string,
|
|
||||||
args: string[],
|
|
||||||
errorContext: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const childProcess: ChildProcess = spawn(claudeExecutable, args, {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.on("close", (code: number | null) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve();
|
|
||||||
} else if (code === null) {
|
|
||||||
reject(new Error(`${errorContext}: process terminated by signal`));
|
|
||||||
} else {
|
|
||||||
reject(new Error(`${errorContext} (exit code: ${code})`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.on("error", (err: Error) => {
|
|
||||||
reject(new Error(`${errorContext}: ${err.message}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs a single Claude Code plugin
|
* Installs a single Claude Code plugin
|
||||||
*/
|
*/
|
||||||
async function installPlugin(
|
export async function installPlugin(
|
||||||
pluginName: string,
|
pluginName: string,
|
||||||
claudeExecutable: string,
|
claudeExecutable: string = "claude",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return executeClaudeCommand(
|
return new Promise((resolve, reject) => {
|
||||||
claudeExecutable,
|
const process = spawn(claudeExecutable, ["plugin", "install", pluginName], {
|
||||||
["plugin", "install", pluginName],
|
stdio: "inherit",
|
||||||
`Failed to install plugin '${pluginName}'`,
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
process.on("close", (code: number | null) => {
|
||||||
* Adds the Claude Code marketplace
|
if (code === 0) {
|
||||||
* @param claudeExecutable - Path to the Claude executable
|
resolve();
|
||||||
* @returns Promise that resolves when the marketplace add command completes
|
} else {
|
||||||
* @throws {Error} If the command fails to execute
|
reject(
|
||||||
*/
|
new Error(
|
||||||
async function addMarketplace(claudeExecutable: string): Promise<void> {
|
`Failed to install plugin '${pluginName}' (exit code: ${code})`,
|
||||||
console.log("Adding Claude Code marketplace...");
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return executeClaudeCommand(
|
process.on("error", (err: Error) => {
|
||||||
claudeExecutable,
|
reject(
|
||||||
["plugin", "marketplace", "add", CLAUDE_CODE_MARKETPLACE_URL],
|
new Error(`Failed to install plugin '${pluginName}': ${err.message}`),
|
||||||
"Failed to add marketplace",
|
);
|
||||||
);
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs Claude Code plugins from a comma-separated list
|
* 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")
|
|
||||||
* @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(
|
export async function installPlugins(
|
||||||
pluginsInput: string | undefined,
|
pluginsInput: string | undefined,
|
||||||
claudeExecutable?: string,
|
claudeExecutable: string = "claude",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const plugins = parsePlugins(pluginsInput);
|
const plugins = parsePlugins(pluginsInput);
|
||||||
|
|
||||||
@@ -137,17 +68,11 @@ export async function installPlugins(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve executable path with explicit fallback
|
|
||||||
const resolvedExecutable = claudeExecutable || "claude";
|
|
||||||
|
|
||||||
// Add marketplace before installing plugins
|
|
||||||
await addMarketplace(resolvedExecutable);
|
|
||||||
|
|
||||||
console.log(`Installing ${plugins.length} plugin(s)...`);
|
console.log(`Installing ${plugins.length} plugin(s)...`);
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
console.log(`Installing plugin: ${plugin}`);
|
console.log(`Installing plugin: ${plugin}`);
|
||||||
await installPlugin(plugin, resolvedExecutable);
|
await installPlugin(plugin, claudeExecutable);
|
||||||
console.log(`✓ Successfully installed: ${plugin}`);
|
console.log(`✓ Successfully installed: ${plugin}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,449 +1,84 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { installPlugins } from "../src/install-plugins";
|
import { parsePlugins } from "../src/install-plugins";
|
||||||
import * as childProcess from "child_process";
|
|
||||||
|
|
||||||
describe("installPlugins", () => {
|
describe("parsePlugins", () => {
|
||||||
let spawnSpy: ReturnType<typeof spyOn> | undefined;
|
test("should return empty array for undefined input", () => {
|
||||||
|
expect(parsePlugins(undefined)).toEqual([]);
|
||||||
afterEach(() => {
|
|
||||||
// Restore original spawn after each test
|
|
||||||
if (spawnSpy) {
|
|
||||||
spawnSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function createMockSpawn(
|
test("should return empty array for empty string", () => {
|
||||||
exitCode: number | null = 0,
|
expect(parsePlugins("")).toEqual([]);
|
||||||
shouldError: boolean = false,
|
|
||||||
) {
|
|
||||||
const mockProcess = {
|
|
||||||
on: mock((event: string, handler: Function) => {
|
|
||||||
if (event === "close" && !shouldError) {
|
|
||||||
// Simulate successful close
|
|
||||||
setTimeout(() => handler(exitCode), 0);
|
|
||||||
} else if (event === "error" && shouldError) {
|
|
||||||
// Simulate error
|
|
||||||
setTimeout(() => handler(new Error("spawn error")), 0);
|
|
||||||
}
|
|
||||||
return mockProcess;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
spawnSpy = spyOn(childProcess, "spawn").mockImplementation(
|
|
||||||
() => mockProcess as any,
|
|
||||||
);
|
|
||||||
return spawnSpy;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("should not call spawn when no plugins are specified", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("");
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not call spawn when plugins is undefined", async () => {
|
test("should return empty array for whitespace-only string", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins(" \n\t ")).toEqual([]);
|
||||||
await installPlugins(undefined);
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not call spawn when plugins is only whitespace", async () => {
|
test("should parse single plugin", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins("feature-dev")).toEqual(["feature-dev"]);
|
||||||
await installPlugins(" ");
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should install a single plugin with default executable", async () => {
|
test("should parse multiple plugins", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins("feature-dev,test-coverage-reviewer")).toEqual([
|
||||||
await installPlugins("test-plugin");
|
"feature-dev",
|
||||||
|
"test-coverage-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
]);
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
// Second call: install plugin
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should install multiple plugins sequentially", async () => {
|
test("should trim whitespace around plugin names", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins(" feature-dev , test-coverage-reviewer ")).toEqual([
|
||||||
await installPlugins("plugin1,plugin2,plugin3");
|
"feature-dev",
|
||||||
|
"test-coverage-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(4);
|
]);
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
// Subsequent calls: install plugins
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
4,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin3"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should use custom claude executable path when provided", async () => {
|
test("should handle spaces between commas", () => {
|
||||||
const spy = createMockSpawn();
|
expect(
|
||||||
await installPlugins("test-plugin", "/custom/path/to/claude");
|
parsePlugins(
|
||||||
|
"feature-dev, test-coverage-reviewer, code-quality-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
),
|
||||||
// First call: add marketplace
|
).toEqual([
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
"feature-dev",
|
||||||
1,
|
"test-coverage-reviewer",
|
||||||
"/custom/path/to/claude",
|
"code-quality-reviewer",
|
||||||
[
|
]);
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
// Second call: install plugin
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"/custom/path/to/claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should trim whitespace from plugin names before installation", async () => {
|
test("should filter out empty values from consecutive commas", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins("feature-dev,,test-coverage-reviewer")).toEqual([
|
||||||
await installPlugins(" plugin1 , plugin2 ");
|
"feature-dev",
|
||||||
|
"test-coverage-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
]);
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should skip empty entries in plugin list", async () => {
|
test("should handle trailing comma", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins("feature-dev,test-coverage-reviewer,")).toEqual([
|
||||||
await installPlugins("plugin1,,plugin2");
|
"feature-dev",
|
||||||
|
"test-coverage-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
]);
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle plugin installation error and throw", async () => {
|
test("should handle leading comma", () => {
|
||||||
createMockSpawn(1, false); // Exit code 1
|
expect(parsePlugins(",feature-dev,test-coverage-reviewer")).toEqual([
|
||||||
|
"feature-dev",
|
||||||
await expect(installPlugins("failing-plugin")).rejects.toThrow(
|
"test-coverage-reviewer",
|
||||||
"Failed to add marketplace (exit code: 1)",
|
]);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle null exit code (process terminated by signal)", async () => {
|
test("should handle plugins with special characters", () => {
|
||||||
createMockSpawn(null, false); // Exit code null (terminated by signal)
|
expect(parsePlugins("@scope/plugin-name,plugin-name-2")).toEqual([
|
||||||
|
"@scope/plugin-name",
|
||||||
await expect(installPlugins("terminated-plugin")).rejects.toThrow(
|
"plugin-name-2",
|
||||||
"Failed to add marketplace: process terminated by signal",
|
]);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should stop installation on first error", async () => {
|
test("should handle complex whitespace patterns", () => {
|
||||||
const spy = createMockSpawn(1, false); // Exit code 1
|
expect(
|
||||||
|
parsePlugins(
|
||||||
await expect(installPlugins("plugin1,plugin2,plugin3")).rejects.toThrow(
|
"\n feature-dev \n,\t test-coverage-reviewer\t, code-quality \n",
|
||||||
"Failed to add marketplace (exit code: 1)",
|
),
|
||||||
);
|
).toEqual(["feature-dev", "test-coverage-reviewer", "code-quality"]);
|
||||||
|
|
||||||
// Should only try to add marketplace before failing
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle plugins with special characters in names", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("org/plugin-name,@scope/plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "org/plugin-name"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "@scope/plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle spawn errors", async () => {
|
|
||||||
createMockSpawn(0, true); // Trigger error event
|
|
||||||
|
|
||||||
await expect(installPlugins("test-plugin")).rejects.toThrow(
|
|
||||||
"Failed to add marketplace: 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");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"/usr/local/bin/claude-custom",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"/usr/local/bin/claude-custom",
|
|
||||||
["plugin", "install", "plugin-a"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"/usr/local/bin/claude-custom",
|
|
||||||
["plugin", "install", "plugin-b"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with command injection attempts", async () => {
|
|
||||||
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",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock should never be called because validation fails first
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with path traversal using ../", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins("../../../malicious-plugin")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with path traversal using ./", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins("./../../@scope/package")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with consecutive dots", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins(".../.../package")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with hidden path traversal", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins("package/../other")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept plugin names with single dots in version numbers", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("plugin-v1.0.2");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin-v1.0.2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept plugin names with multiple dots in semantic versions", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("@scope/plugin-v1.0.0-beta.1");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/anthropics/claude-code.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "@scope/plugin-v1.0.0-beta.1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject Unicode homoglyph path traversal attempts", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
|
|
||||||
await expect(installPlugins("../malicious")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject path traversal at end of path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins("package/..")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject single dot directory reference", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins("package/.")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject path traversal in middle of path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins("package/../other")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ jobs:
|
|||||||
# --max-turns 10
|
# --max-turns 10
|
||||||
# --model claude-4-0-sonnet-20250805
|
# --model claude-4-0-sonnet-20250805
|
||||||
|
|
||||||
# Optional: install Claude Code plugins
|
|
||||||
# plugins: "plugin1,plugin2,plugin3"
|
|
||||||
|
|
||||||
# Optional: add custom trigger phrase (default: @claude)
|
# Optional: add custom trigger phrase (default: @claude)
|
||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
# Optional: add assignee trigger for issues
|
# Optional: add assignee trigger for issues
|
||||||
@@ -50,33 +47,32 @@ jobs:
|
|||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
| Input | Description | Required | Default |
|
| Input | Description | Required | Default |
|
||||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- |
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------- |
|
||||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||||
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
||||||
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
||||||
| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
| `claude_args` | Additional arguments to pass directly to Claude CLI (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
||||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
||||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||||
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
||||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
||||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||||
| `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 | "" |
|
| `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_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 | "" |
|
| `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 | "" |
|
|
||||||
|
|
||||||
### Deprecated Inputs
|
### Deprecated Inputs
|
||||||
|
|
||||||
|
|||||||
@@ -684,7 +684,7 @@ ${
|
|||||||
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
||||||
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
||||||
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
||||||
- Your comment must always include the job run link in the format "[View job run](${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID})" at the bottom of your response (branch link if there is one should also be included there).
|
- Your comment must always include the job run link (and branch link if there is one) at the bottom.
|
||||||
|
|
||||||
CAPABILITIES AND LIMITATIONS:
|
CAPABILITIES AND LIMITATIONS:
|
||||||
When users ask you to do something, be aware of what you can and cannot do. This section helps you understand how to respond when users request actions outside your scope.
|
When users ask you to do something, be aware of what you can and cannot do. This section helps you understand how to respond when users request actions outside your scope.
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ type BaseContext = {
|
|||||||
allowedBots: string;
|
allowedBots: string;
|
||||||
allowedNonWriteUsers: string;
|
allowedNonWriteUsers: string;
|
||||||
trackProgress: boolean;
|
trackProgress: boolean;
|
||||||
plugins: string[];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,10 +150,6 @@ export function parseGitHubContext(): GitHubContext {
|
|||||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||||
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
|
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
|
||||||
trackProgress: process.env.TRACK_PROGRESS === "true",
|
trackProgress: process.env.TRACK_PROGRESS === "true",
|
||||||
plugins: (process.env.PLUGINS || "")
|
|
||||||
.split(",")
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter((p) => p.length > 0),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -134,17 +134,11 @@ export async function prepareMcpConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if code-review plugin is in the plugins list
|
|
||||||
const hasCodeReviewPlugin = context.inputs.plugins.includes(
|
|
||||||
"code-review@claude-code-plugins",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Include inline comment server for PRs when requested via allowed tools
|
// Include inline comment server for PRs when requested via allowed tools
|
||||||
// or when code-review plugin is specified (needs inline comment access for reviews)
|
|
||||||
if (
|
if (
|
||||||
isEntityContext(context) &&
|
isEntityContext(context) &&
|
||||||
context.isPR &&
|
context.isPR &&
|
||||||
(hasGitHubMcpTools || hasInlineCommentTools || hasCodeReviewPlugin)
|
(hasGitHubMcpTools || hasInlineCommentTools)
|
||||||
) {
|
) {
|
||||||
baseMcpConfig.mcpServers.github_inline_comment = {
|
baseMcpConfig.mcpServers.github_inline_comment = {
|
||||||
command: "bun",
|
command: "bun",
|
||||||
|
|||||||
@@ -114,14 +114,6 @@ export const agentMode: Mode = {
|
|||||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||||
const allowedTools = parseAllowedTools(userClaudeArgs);
|
const allowedTools = parseAllowedTools(userClaudeArgs);
|
||||||
|
|
||||||
// Add inline comment tool if code-review plugin is present
|
|
||||||
const hasCodeReviewPlugin = context.inputs.plugins.includes(
|
|
||||||
"code-review@claude-code-plugins",
|
|
||||||
);
|
|
||||||
if (hasCodeReviewPlugin && isEntityContext(context) && context.isPR) {
|
|
||||||
allowedTools.push("mcp__github_inline_comment__create_inline_comment");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for branch info from environment variables (useful for auto-fix workflows)
|
// Check for branch info from environment variables (useful for auto-fix workflows)
|
||||||
const claudeBranch = process.env.CLAUDE_BRANCH || undefined;
|
const claudeBranch = process.env.CLAUDE_BRANCH || undefined;
|
||||||
const baseBranch =
|
const baseBranch =
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ describe("prepareMcpConfig", () => {
|
|||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
plugins: [],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,111 +276,4 @@ describe("prepareMcpConfig", () => {
|
|||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should include inline comment server in agent mode when code-review plugin is specified", async () => {
|
|
||||||
const contextWithCodeReviewPlugin: ParsedGitHubContext = {
|
|
||||||
...mockPRContext,
|
|
||||||
inputs: {
|
|
||||||
...mockPRContext.inputs,
|
|
||||||
plugins: ["code-review@claude-code-plugins"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
baseBranch: "main",
|
|
||||||
allowedTools: [],
|
|
||||||
mode: "agent",
|
|
||||||
context: contextWithCodeReviewPlugin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
|
||||||
expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe(
|
|
||||||
"test-token",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not include inline comment server in agent mode when code-review plugin is not specified", async () => {
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
baseBranch: "main",
|
|
||||||
allowedTools: [],
|
|
||||||
mode: "agent",
|
|
||||||
context: mockPRContext,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.github_inline_comment).not.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include inline comment server in agent mode when code-review plugin is in a list of plugins", async () => {
|
|
||||||
const contextWithMultiplePlugins: ParsedGitHubContext = {
|
|
||||||
...mockPRContext,
|
|
||||||
inputs: {
|
|
||||||
...mockPRContext.inputs,
|
|
||||||
plugins: ["plugin1", "code-review@claude-code-plugins", "plugin2"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
baseBranch: "main",
|
|
||||||
allowedTools: [],
|
|
||||||
mode: "agent",
|
|
||||||
context: contextWithMultiplePlugins,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not include inline comment server in agent mode when plugins contain similar but not exact match", async () => {
|
|
||||||
const contextWithSimilarPlugin: ParsedGitHubContext = {
|
|
||||||
...mockPRContext,
|
|
||||||
inputs: {
|
|
||||||
...mockPRContext.inputs,
|
|
||||||
plugins: ["code-review-other", "review@claude-code-plugins"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
baseBranch: "main",
|
|
||||||
allowedTools: [],
|
|
||||||
mode: "agent",
|
|
||||||
context: contextWithSimilarPlugin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.github_inline_comment).not.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include inline comment server in agent mode when explicit inline comment tools are provided (backward compatibility)", async () => {
|
|
||||||
const result = await prepareMcpConfig({
|
|
||||||
githubToken: "test-token",
|
|
||||||
owner: "test-owner",
|
|
||||||
repo: "test-repo",
|
|
||||||
branch: "test-branch",
|
|
||||||
baseBranch: "main",
|
|
||||||
allowedTools: ["mcp__github_inline_comment__create_inline_comment"],
|
|
||||||
mode: "agent",
|
|
||||||
context: mockPRContext,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
|
||||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const defaultInputs = {
|
|||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
plugins: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ describe("detectMode with enhanced routing", () => {
|
|||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
plugins: [],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
|
|||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
plugins: [],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user