Compare commits

..

3 Commits

Author SHA1 Message Date
Ashwin Bhat
5940655715 Default systemPrompt to claude_code preset
Without an explicit systemPrompt, the SDK would use no system prompt.
Now it defaults to the claude_code preset to match CLI behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 16:21:16 -08:00
Ashwin Bhat
0c4374199a Add settingSources to SDK options to load plugins
Without settingSources, the SDK doesn't load any filesystem settings,
which means plugins installed via CLI aren't available. Adding
settingSources: ['user', 'project', 'local'] ensures CLI-installed
plugins and CLAUDE.md files are loaded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 16:20:33 -08:00
Ashwin Bhat
c67fb66a79 Add debug logging to Agent SDK path
Temporary debugging to investigate SDK behavior in CI:
- Log raw and parsed options
- Log prompt content and length
- Log each message received from query()
- Log final result message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 16:09:03 -08:00
25 changed files with 367 additions and 1251 deletions

View File

@@ -0,0 +1,132 @@
name: Bump Claude Code Version
on:
repository_dispatch:
types: [bump_claude_code_version]
workflow_dispatch:
inputs:
version:
description: "Claude Code version to bump to"
required: true
type: string
permissions:
contents: write
jobs:
bump-version:
name: Bump Claude Code Version
runs-on: ubuntu-latest
environment: release
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4
with:
token: ${{ secrets.RELEASE_PAT }}
fetch-depth: 0
- name: Get version from event payload
id: get_version
run: |
# Get version from either repository_dispatch or workflow_dispatch
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
NEW_VERSION="${CLIENT_PAYLOAD_VERSION}"
else
NEW_VERSION="${INPUT_VERSION}"
fi
# Sanitize the version to avoid issues enabled by problematic characters
NEW_VERSION=$(echo "$NEW_VERSION" | tr -d '`;$(){}[]|&<>' | tr -s ' ' '-')
if [ -z "$NEW_VERSION" ]; then
echo "Error: version not provided"
exit 1
fi
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
env:
INPUT_VERSION: ${{ inputs.version }}
CLIENT_PAYLOAD_VERSION: ${{ github.event.client_payload.version }}
- name: Create branch and update base-action/action.yml
run: |
# Variables
TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
BRANCH_NAME="bump-claude-code-${{ env.NEW_VERSION }}-$TIMESTAMP"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
# Get the default branch
DEFAULT_BRANCH=$(gh api repos/${GITHUB_REPOSITORY} --jq '.default_branch')
echo "DEFAULT_BRANCH=$DEFAULT_BRANCH" >> $GITHUB_ENV
# Get the latest commit SHA from the default branch
BASE_SHA=$(gh api repos/${GITHUB_REPOSITORY}/git/refs/heads/$DEFAULT_BRANCH --jq '.object.sha')
# Create a new branch
gh api \
--method POST \
repos/${GITHUB_REPOSITORY}/git/refs \
-f ref="refs/heads/$BRANCH_NAME" \
-f sha="$BASE_SHA"
# Get the current base-action/action.yml content
ACTION_CONTENT=$(gh api repos/${GITHUB_REPOSITORY}/contents/base-action/action.yml?ref=$DEFAULT_BRANCH --jq '.content' | base64 -d)
# Update the Claude Code version in the npm install command
UPDATED_CONTENT=$(echo "$ACTION_CONTENT" | sed -E "s/(npm install -g @anthropic-ai\/claude-code@)[0-9]+\.[0-9]+\.[0-9]+/\1${{ env.NEW_VERSION }}/")
# Verify the change would be made
if ! echo "$UPDATED_CONTENT" | grep -q "@anthropic-ai/claude-code@${{ env.NEW_VERSION }}"; then
echo "Error: Failed to update Claude Code version in content"
exit 1
fi
# Get the current SHA of base-action/action.yml for the update API call
FILE_SHA=$(gh api repos/${GITHUB_REPOSITORY}/contents/base-action/action.yml?ref=$DEFAULT_BRANCH --jq '.sha')
# Create the updated base-action/action.yml content in base64
echo "$UPDATED_CONTENT" | base64 > action.yml.b64
# Commit the updated base-action/action.yml via GitHub API
gh api \
--method PUT \
repos/${GITHUB_REPOSITORY}/contents/base-action/action.yml \
-f message="chore: bump Claude Code version to ${{ env.NEW_VERSION }}" \
-F content=@action.yml.b64 \
-f sha="$FILE_SHA" \
-f branch="$BRANCH_NAME"
echo "Successfully created branch and updated Claude Code version to ${{ env.NEW_VERSION }}"
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
GITHUB_REPOSITORY: ${{ github.repository }}
- name: Create Pull Request
run: |
# Determine trigger type for PR body
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
TRIGGER_INFO="repository dispatch event"
else
TRIGGER_INFO="manual workflow dispatch by @${GITHUB_ACTOR}"
fi
# Create PR body with proper YAML escape
printf -v PR_BODY "## Bump Claude Code to ${{ env.NEW_VERSION }}\n\nThis PR updates the Claude Code version in base-action/action.yml to ${{ env.NEW_VERSION }}.\n\n### Changes\n- Updated Claude Code version from current to \`${{ env.NEW_VERSION }}\`\n\n### Triggered by\n- $TRIGGER_INFO\n\n🤖 This PR was automatically created by the bump-claude-code-version workflow."
echo "Creating PR with gh pr create command"
PR_URL=$(gh pr create \
--repo "${GITHUB_REPOSITORY}" \
--title "chore: bump Claude Code version to ${{ env.NEW_VERSION }}" \
--body "$PR_BODY" \
--base "${DEFAULT_BRANCH}" \
--head "${BRANCH_NAME}")
echo "PR created successfully: $PR_URL"
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_ACTOR: ${{ github.actor }}
DEFAULT_BRANCH: ${{ env.DEFAULT_BRANCH }}
BRANCH_NAME: ${{ env.BRANCH_NAME }}

View File

@@ -36,4 +36,4 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--allowedTools "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
--model "claude-opus-4-5"
--model "claude-opus-4-1-20250805"

View File

@@ -94,5 +94,5 @@ jobs:
echo "✅ Successfully synced \`base-action\` directory to [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Source commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/anthropics/claude-code-action/commit/${GITHUB_SHA})" >> $GITHUB_STEP_SUMMARY
echo "- **Triggered by**: $GITHUB_EVENT_NAME" >> $GITHUB_STEP_SUMMARY
echo "- **Actor**: @$GITHUB_ACTOR" >> $GITHUB_STEP_SUMMARY
echo "- **Triggered by**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
echo "- **Actor**: @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY

View File

@@ -120,16 +120,13 @@ outputs:
value: ${{ steps.claude-code.outputs.execution_file }}
branch_name:
description: "The branch created by Claude Code for this execution"
value: ${{ steps.claude-code.outputs.CLAUDE_BRANCH }}
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
github_token:
description: "The GitHub token used by the action (Claude App token if available)"
value: ${{ steps.claude-code.outputs.GITHUB_TOKEN }}
value: ${{ steps.prepare.outputs.github_token }}
structured_output:
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
value: ${{ steps.claude-code.outputs.structured_output }}
session_id:
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
value: ${{ steps.claude-code.outputs.session_id }}
runs:
using: "composite"
@@ -143,66 +140,24 @@ runs:
- name: Setup Custom Bun Path
if: inputs.path_to_bun_executable != ''
shell: bash
env:
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
run: |
echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}"
# Add the directory containing the custom executable to PATH
BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
echo "$BUN_DIR" >> "$GITHUB_PATH"
- name: Install Dependencies
shell: bash
env:
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
run: |
# Install main action dependencies
cd ${GITHUB_ACTION_PATH}
bun install
# Install base-action dependencies
echo "Installing base-action dependencies..."
cd ${GITHUB_ACTION_PATH}/base-action
bun install
echo "Base-action dependencies installed"
cd -
# Install Claude Code if no custom executable is provided
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.0.74"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do
echo "Installation attempt $attempt..."
if command -v timeout &> /dev/null; then
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
else
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
fi
if [ $attempt -eq 3 ]; then
echo "Failed to install Claude Code after 3 attempts"
exit 1
fi
echo "Installation failed, retrying..."
sleep 5
done
echo "Claude Code installed successfully"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
# Add the directory containing the custom executable to PATH
CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
fi
# Unified step: prepare + setup + run Claude
- name: Run Claude Code
id: claude-code
- name: Prepare action
id: prepare
shell: bash
run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
env:
# Action configuration
CLAUDE_CODE_ACTION: "1"
MODE: ${{ inputs.mode }}
PROMPT: ${{ inputs.prompt }}
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
@@ -224,16 +179,67 @@ runs:
CLAUDE_ARGS: ${{ inputs.claude_args }}
ALL_INPUTS: ${{ toJson(inputs) }}
- name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true'
shell: bash
run: |
echo "Installing base-action dependencies..."
cd ${GITHUB_ACTION_PATH}/base-action
bun install
echo "Base-action dependencies installed"
cd -
# Install Claude Code if no custom executable is provided
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
CLAUDE_CODE_VERSION="2.0.60"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do
echo "Installation attempt $attempt..."
if command -v timeout &> /dev/null; then
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
else
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
fi
if [ $attempt -eq 3 ]; then
echo "Failed to install Claude Code after 3 attempts"
exit 1
fi
echo "Installation failed, retrying..."
sleep 5
done
echo "Claude Code installed successfully"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
fi
- name: Run Claude Code
id: claude-code
if: steps.prepare.outputs.contains_trigger == 'true'
shell: bash
run: |
# Run the base-action
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
env:
# Base-action inputs
CLAUDE_CODE_ACTION: "1"
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
INPUT_SETTINGS: ${{ inputs.settings }}
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
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 }}
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_PLUGINS: ${{ inputs.plugins }}
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
# Model configuration
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
NODE_VERSION: ${{ env.NODE_VERSION }}
DETAILED_PERMISSION_MESSAGES: "1"
@@ -273,33 +279,32 @@ runs:
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
- name: Update comment with job link
if: steps.claude-code.outputs.contains_trigger == 'true' && steps.claude-code.outputs.claude_comment_id && always()
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
shell: bash
run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts
env:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
CLAUDE_COMMENT_ID: ${{ steps.claude-code.outputs.claude_comment_id }}
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }}
GITHUB_RUN_ID: ${{ github.run_id }}
GITHUB_TOKEN: ${{ steps.claude-code.outputs.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.claude-code.outputs.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
CLAUDE_BRANCH: ${{ steps.claude-code.outputs.CLAUDE_BRANCH }}
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
BASE_BRANCH: ${{ steps.claude-code.outputs.BASE_BRANCH }}
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
PREPARE_SUCCESS: ${{ steps.claude-code.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.claude-code.outputs.prepare_error || '' }}
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
TRACK_PROGRESS: ${{ inputs.track_progress }}
- name: Display Claude Code Report
if: steps.claude-code.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
shell: bash
run: |
# Try to format the turns, but if it fails, dump the raw JSON
@@ -316,12 +321,12 @@ runs:
fi
- name: Revoke app token
if: always() && inputs.github_token == '' && steps.claude-code.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
shell: bash
run: |
curl -L \
-X DELETE \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ steps.claude-code.outputs.GITHUB_TOKEN }}" \
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
${GITHUB_API_URL:-https://api.github.com}/installation/token

View File

@@ -82,9 +82,6 @@ outputs:
structured_output:
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
value: ${{ steps.run_claude.outputs.structured_output }}
session_id:
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
value: ${{ steps.run_claude.outputs.session_id }}
runs:
using: "composite"
@@ -104,12 +101,10 @@ runs:
- name: Setup Custom Bun Path
if: inputs.path_to_bun_executable != ''
shell: bash
env:
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
run: |
echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}"
# Add the directory containing the custom executable to PATH
BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
echo "$BUN_DIR" >> "$GITHUB_PATH"
- name: Install Dependencies
@@ -120,11 +115,9 @@ runs:
- name: Install Claude Code
shell: bash
env:
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
run: |
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.0.74"
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
CLAUDE_CODE_VERSION="2.0.60"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do
echo "Installation attempt $attempt..."
@@ -142,9 +135,9 @@ runs:
done
echo "Claude Code installed successfully"
else
echo "Using custom Claude Code executable: $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
CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
fi

View File

@@ -1,12 +1,11 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@anthropic-ai/claude-code-base-action",
"dependencies": {
"@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
"@anthropic-ai/claude-agent-sdk": "^0.1.52",
"shell-quote": "^1.8.3",
},
"devDependencies": {
@@ -27,7 +26,7 @@
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.74", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1 || ^4.0.0" } }, "sha512-d6H3Oo625WAG3BrBFKJsuSshi4f0amc0kTJTm83LRPPFxn9kfq58FX4Oxxt+RUD9N3QumW9sQSEDnri20/F4qQ=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.52", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-yF8N05+9NRbqYA/h39jQ726HTQFrdXXp7pEfDNKIJ2c4FdWvEjxBA/8ciZIebN6/PyvGDcbEp3yq2Co4rNpg6A=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],

View File

@@ -11,7 +11,7 @@
},
"dependencies": {
"@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
"@anthropic-ai/claude-agent-sdk": "^0.1.52",
"shell-quote": "^1.8.3"
},
"devDependencies": {

View File

@@ -1,13 +0,0 @@
/**
* Library exports for the base-action.
* These functions can be imported directly by the main action
* to avoid file/output-based communication between steps.
*/
export { runClaudeWithSdk, runClaudeWithSdkFromFile } from "./run-claude-sdk";
export type { RunClaudeResult, PromptInput } from "./run-claude-sdk";
export { setupClaudeCodeSettings } from "./setup-claude-code-settings";
export { installPlugins } from "./install-plugins";
export { parseSdkOptions } from "./parse-sdk-options";
export type { ClaudeOptions } from "./run-claude";
export type { ParsedSdkOptions } from "./parse-sdk-options";

View File

@@ -12,79 +12,12 @@ export type ParsedSdkOptions = {
};
// Flags that should accumulate multiple values instead of overwriting
// Include both camelCase and hyphenated variants for CLI compatibility
const ACCUMULATING_FLAGS = new Set([
"allowedTools",
"allowed-tools",
"disallowedTools",
"disallowed-tools",
"mcp-config",
]);
// Delimiter used to join accumulated flag values
const ACCUMULATE_DELIMITER = "\x00";
type McpConfig = {
mcpServers?: Record<string, unknown>;
};
/**
* Merge multiple MCP config values into a single config.
* Each config can be a JSON string or a file path.
* For JSON strings, mcpServers objects are merged.
* For file paths, they are kept as-is (user's file takes precedence and is used last).
*/
function mergeMcpConfigs(configValues: string[]): string {
const merged: McpConfig = { mcpServers: {} };
let lastFilePath: string | null = null;
for (const config of configValues) {
const trimmed = config.trim();
if (!trimmed) continue;
// Check if it's a JSON string (starts with {) or a file path
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed) as McpConfig;
if (parsed.mcpServers) {
Object.assign(merged.mcpServers!, parsed.mcpServers);
}
} catch {
// If JSON parsing fails, treat as file path
lastFilePath = trimmed;
}
} else {
// It's a file path - store it to handle separately
lastFilePath = trimmed;
}
}
// If we have file paths, we need to keep the merged JSON and let the file
// be handled separately. Since we can only return one value, merge what we can.
// If there's a file path, we need a different approach - read the file at runtime.
// For now, if there's a file path, we'll stringify the merged config.
// The action prepends its config as JSON, so we can safely merge inline JSON configs.
// If no inline configs were found (all file paths), return the last file path
if (Object.keys(merged.mcpServers!).length === 0 && lastFilePath) {
return lastFilePath;
}
// Note: If user passes a file path, we cannot merge it at parse time since
// we don't have access to the file system here. The action's built-in MCP
// servers are always passed as inline JSON, so they will be merged.
// If user also passes inline JSON, it will be merged.
// If user passes a file path, they should ensure it includes all needed servers.
return JSON.stringify(merged);
}
const ACCUMULATING_FLAGS = new Set(["allowedTools", "disallowedTools"]);
/**
* Parse claudeArgs string into extraArgs record for SDK pass-through
* The SDK/CLI will handle --mcp-config, --json-schema, etc.
* For allowedTools and disallowedTools, multiple occurrences are accumulated (null-char joined).
* Accumulating flags also consume all consecutive non-flag values
* (e.g., --allowed-tools "Tool1" "Tool2" "Tool3" captures all three).
* For allowedTools and disallowedTools, multiple occurrences are accumulated (comma-joined).
*/
function parseClaudeArgsToExtraArgs(
claudeArgs?: string,
@@ -104,25 +37,13 @@ function parseClaudeArgsToExtraArgs(
// Check if next arg is a value (not another flag)
if (nextArg && !nextArg.startsWith("--")) {
// For accumulating flags, consume all consecutive non-flag values
// This handles: --allowed-tools "Tool1" "Tool2" "Tool3"
if (ACCUMULATING_FLAGS.has(flag)) {
const values: string[] = [];
while (i + 1 < args.length && !args[i + 1]?.startsWith("--")) {
i++;
values.push(args[i]!);
}
const joinedValues = values.join(ACCUMULATE_DELIMITER);
if (result[flag]) {
result[flag] =
`${result[flag]}${ACCUMULATE_DELIMITER}${joinedValues}`;
} else {
result[flag] = joinedValues;
}
// For accumulating flags, join multiple values with commas
if (ACCUMULATING_FLAGS.has(flag) && result[flag]) {
result[flag] = `${result[flag]},${nextArg}`;
} else {
result[flag] = nextArg;
i++; // Skip the value
}
i++; // Skip the value
} else {
result[flag] = null; // Boolean flag
}
@@ -147,23 +68,12 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
// Detect if --json-schema is present (for hasJsonSchema flag)
const hasJsonSchema = "json-schema" in extraArgs;
// Extract and merge allowedTools from all sources:
// Extract and merge allowedTools from both sources:
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
// 2. From options.allowedTools (direct input - may be undefined)
// This prevents duplicate flags being overwritten when claudeArgs contains --allowedTools
const allowedToolsValues = [
extraArgs["allowedTools"],
extraArgs["allowed-tools"],
]
.filter(Boolean)
.join(ACCUMULATE_DELIMITER);
const extraArgsAllowedTools = allowedToolsValues
? allowedToolsValues
.split(ACCUMULATE_DELIMITER)
.flatMap((v) => v.split(","))
.map((t) => t.trim())
.filter(Boolean)
const extraArgsAllowedTools = extraArgs["allowedTools"]
? extraArgs["allowedTools"].split(",").map((t) => t.trim())
: [];
const directAllowedTools = options.allowedTools
? options.allowedTools.split(",").map((t) => t.trim())
@@ -172,21 +82,10 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
...new Set([...extraArgsAllowedTools, ...directAllowedTools]),
];
delete extraArgs["allowedTools"];
delete extraArgs["allowed-tools"];
// Same for disallowedTools - check both camelCase and hyphenated variants
const disallowedToolsValues = [
extraArgs["disallowedTools"],
extraArgs["disallowed-tools"],
]
.filter(Boolean)
.join(ACCUMULATE_DELIMITER);
const extraArgsDisallowedTools = disallowedToolsValues
? disallowedToolsValues
.split(ACCUMULATE_DELIMITER)
.flatMap((v) => v.split(","))
.map((t) => t.trim())
.filter(Boolean)
// Same for disallowedTools
const extraArgsDisallowedTools = extraArgs["disallowedTools"]
? extraArgs["disallowedTools"].split(",").map((t) => t.trim())
: [];
const directDisallowedTools = options.disallowedTools
? options.disallowedTools.split(",").map((t) => t.trim())
@@ -195,17 +94,6 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
...new Set([...extraArgsDisallowedTools, ...directDisallowedTools]),
];
delete extraArgs["disallowedTools"];
delete extraArgs["disallowed-tools"];
// Merge multiple --mcp-config values by combining their mcpServers objects
// The action prepends its config (github_comment, github_ci, etc.) as inline JSON,
// and users may provide their own config as inline JSON or file path
if (extraArgs["mcp-config"]) {
const mcpConfigValues = extraArgs["mcp-config"].split(ACCUMULATE_DELIMITER);
if (mcpConfigValues.length > 1) {
extraArgs["mcp-config"] = mergeMcpConfigs(mcpConfigValues);
}
}
// Build custom environment
const env: Record<string, string | undefined> = { ...process.env };
@@ -249,18 +137,10 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
extraArgs,
env,
// Load settings from sources - prefer user's --setting-sources if provided, otherwise use all sources
// This ensures users can override the default behavior (e.g., --setting-sources user to avoid in-repo configs)
settingSources: extraArgs["setting-sources"]
? (extraArgs["setting-sources"].split(
",",
) as SdkOptions["settingSources"])
: ["user", "project", "local"],
// Load settings from all sources to pick up CLI-installed plugins, CLAUDE.md, etc.
settingSources: ["user", "project", "local"],
};
// Remove setting-sources from extraArgs to avoid passing it twice
delete extraArgs["setting-sources"];
return {
sdkOptions,
showFullOutput,

View File

@@ -9,18 +9,6 @@ import type { ParsedSdkOptions } from "./parse-sdk-options";
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
/**
* Result of running Claude via SDK
*/
export type RunClaudeResult = {
success: boolean;
executionFile: string;
conclusion: "success" | "failure";
structuredOutput?: string;
sessionId?: string;
error?: string;
};
/**
* Sanitizes SDK output to match CLI sanitization behavior
*/
@@ -69,31 +57,13 @@ function sanitizeSdkOutput(
}
/**
* Input for runClaudeWithSdk - either a prompt string or file path
*/
export type PromptInput =
| { type: "string"; prompt: string }
| { type: "file"; promptPath: string };
/**
* Run Claude using the Agent SDK.
*
* @param promptInput - Either a direct prompt string or path to prompt file
* @param parsedOptions - Parsed SDK options
* @param options - Additional options
* @param options.setOutputs - Whether to set GitHub Action outputs (default: true for backwards compat)
* @returns Result of the execution
* Run Claude using the Agent SDK
*/
export async function runClaudeWithSdk(
promptInput: PromptInput,
promptPath: string,
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
{ setOutputs = true }: { setOutputs?: boolean } = {},
): Promise<RunClaudeResult> {
// Get prompt from string or file
const prompt =
promptInput.type === "string"
? promptInput.prompt
: await readFile(promptInput.promptPath, "utf-8");
): Promise<void> {
const prompt = await readFile(promptPath, "utf-8");
if (!showFullOutput) {
console.log(
@@ -104,22 +74,29 @@ export async function runClaudeWithSdk(
);
}
if (promptInput.type === "file") {
console.log(
`Running Claude with prompt from file: ${promptInput.promptPath}`,
);
} else {
console.log(`Running Claude with prompt string (${prompt.length} chars)`);
}
// Log SDK options without env (which could contain sensitive data)
const { env, ...optionsToLog } = sdkOptions;
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
console.log(`Running Claude with prompt from file: ${promptPath}`);
console.log(
"[DEBUG] Prompt content (first 2000 chars):",
prompt.substring(0, 2000),
);
console.log("[DEBUG] Prompt length:", prompt.length);
console.log(
"[DEBUG] sdkOptions passed to query():",
JSON.stringify(sdkOptions, null, 2),
);
const messages: SDKMessage[] = [];
let resultMessage: SDKResultMessage | undefined;
try {
console.log("[DEBUG] About to call query()...");
for await (const message of query({ prompt, options: sdkOptions })) {
console.log(
"[DEBUG] Received message type:",
message.type,
"subtype:",
(message as any).subtype,
);
messages.push(message);
const sanitized = sanitizeSdkOutput(message, showFullOutput);
@@ -129,51 +106,39 @@ export async function runClaudeWithSdk(
if (message.type === "result") {
resultMessage = message as SDKResultMessage;
console.log(
"[DEBUG] Got result message:",
JSON.stringify(resultMessage, null, 2),
);
}
}
console.log(
"[DEBUG] Finished iterating query(), total messages:",
messages.length,
);
} catch (error) {
console.error("SDK execution error:", error);
if (setOutputs) {
core.setOutput("conclusion", "failure");
}
return {
success: false,
executionFile: EXECUTION_FILE,
conclusion: "failure",
error: String(error),
};
core.setOutput("conclusion", "failure");
process.exit(1);
}
// Write execution file
try {
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
console.log(`Log saved to ${EXECUTION_FILE}`);
if (setOutputs) {
core.setOutput("execution_file", EXECUTION_FILE);
}
core.setOutput("execution_file", EXECUTION_FILE);
} catch (error) {
core.warning(`Failed to write execution file: ${error}`);
}
if (!resultMessage) {
if (setOutputs) {
core.setOutput("conclusion", "failure");
}
core.setOutput("conclusion", "failure");
core.error("No result message received from Claude");
return {
success: false,
executionFile: EXECUTION_FILE,
conclusion: "failure",
error: "No result message received from Claude",
};
process.exit(1);
}
const isSuccess = resultMessage.subtype === "success";
if (setOutputs) {
core.setOutput("conclusion", isSuccess ? "success" : "failure");
}
let structuredOutput: string | undefined;
core.setOutput("conclusion", isSuccess ? "success" : "failure");
// Handle structured output
if (hasJsonSchema) {
@@ -182,64 +147,26 @@ export async function runClaudeWithSdk(
"structured_output" in resultMessage &&
resultMessage.structured_output
) {
structuredOutput = JSON.stringify(resultMessage.structured_output);
if (setOutputs) {
core.setOutput("structured_output", structuredOutput);
}
const structuredOutputJson = JSON.stringify(
resultMessage.structured_output,
);
core.setOutput("structured_output", structuredOutputJson);
core.info(
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
);
} else {
const errorMsg = `--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`;
if (setOutputs) {
core.setFailed(errorMsg);
core.setOutput("conclusion", "failure");
}
return {
success: false,
executionFile: EXECUTION_FILE,
conclusion: "failure",
error: errorMsg,
};
core.setFailed(
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
);
core.setOutput("conclusion", "failure");
process.exit(1);
}
}
if (!isSuccess) {
const errors =
"errors" in resultMessage && resultMessage.errors
? resultMessage.errors.join(", ")
: "Unknown error";
core.error(`Execution failed: ${errors}`);
return {
success: false,
executionFile: EXECUTION_FILE,
conclusion: "failure",
error: errors,
};
}
return {
success: true,
executionFile: EXECUTION_FILE,
conclusion: "success",
structuredOutput,
};
}
/**
* Wrapper for backwards compatibility - reads prompt from file path and exits on failure
*/
export async function runClaudeWithSdkFromFile(
promptPath: string,
parsedOptions: ParsedSdkOptions,
): Promise<void> {
const result = await runClaudeWithSdk(
{ type: "file", promptPath },
parsedOptions,
{ setOutputs: true },
);
if (!result.success) {
if ("errors" in resultMessage && resultMessage.errors) {
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
}
process.exit(1);
}
}

View File

@@ -5,7 +5,7 @@ import { unlink, writeFile, stat, readFile } from "fs/promises";
import { createWriteStream } from "fs";
import { spawn } from "child_process";
import { parse as parseShellArgs } from "shell-quote";
import { runClaudeWithSdkFromFile } from "./run-claude-sdk";
import { runClaudeWithSdk } from "./run-claude-sdk";
import { parseSdkOptions } from "./parse-sdk-options";
const execAsync = promisify(exec);
@@ -124,36 +124,6 @@ export function prepareRunConfig(
};
}
/**
* Parses session_id from execution file and sets GitHub Action output
* Exported for testing
*/
export async function parseAndSetSessionId(
executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
const messages = JSON.parse(content) as {
type: string;
subtype?: string;
session_id?: string;
}[];
// Find the system.init message which contains session_id
const initMessage = messages.find(
(m) => m.type === "system" && m.subtype === "init",
);
if (initMessage?.session_id) {
core.setOutput("session_id", initMessage.session_id);
core.info(`Set session_id: ${initMessage.session_id}`);
}
} catch (error) {
// Don't fail the action if session_id extraction fails
core.warning(`Failed to extract session_id: ${error}`);
}
}
/**
* Parses structured_output from execution file and sets GitHub Action outputs
* Only runs if --json-schema was explicitly provided in claude_args
@@ -197,15 +167,23 @@ export async function parseAndSetStructuredOutputs(
}
export async function runClaude(promptPath: string, options: ClaudeOptions) {
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
// Feature flag: use SDK path when USE_AGENT_SDK=true
const useAgentSdk = process.env.USE_AGENT_SDK === "true";
console.log(
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
);
if (useAgentSdk) {
console.log(
"[DEBUG] Raw options passed to SDK path:",
JSON.stringify(options, null, 2),
);
const parsedOptions = parseSdkOptions(options);
return runClaudeWithSdkFromFile(promptPath, parsedOptions);
console.log(
"[DEBUG] Parsed SDK options:",
JSON.stringify(parsedOptions, null, 2),
);
return runClaudeWithSdk(promptPath, parsedOptions);
}
const config = prepareRunConfig(promptPath, options);
@@ -398,9 +376,6 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
core.setOutput("execution_file", EXECUTION_FILE);
// Extract and set session_id
await parseAndSetSessionId(EXECUTION_FILE);
// Parse and set structured outputs only if user provided --json-schema in claude_args
if (hasJsonSchema) {
try {

View File

@@ -108,48 +108,6 @@ describe("parseSdkOptions", () => {
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
});
test("should handle hyphenated --allowed-tools flag", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowed-tools "Edit,Read,Write"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
expect(result.sdkOptions.extraArgs?.["allowed-tools"]).toBeUndefined();
});
test("should accumulate multiple --allowed-tools flags (hyphenated)", () => {
// This is the exact scenario from issue #746
const options: ClaudeOptions = {
claudeArgs:
'--allowed-tools "Bash(git log:*)" "Bash(git diff:*)" "Bash(git fetch:*)" "Bash(gh pr:*)"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual([
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git fetch:*)",
"Bash(gh pr:*)",
]);
});
test("should handle mixed camelCase and hyphenated allowedTools flags", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"',
};
const result = parseSdkOptions(options);
// Both should be merged - note: order depends on which key is found first
expect(result.sdkOptions.allowedTools).toContain("Edit");
expect(result.sdkOptions.allowedTools).toContain("Read");
expect(result.sdkOptions.allowedTools).toContain("Write");
expect(result.sdkOptions.allowedTools).toContain("Glob");
});
});
describe("disallowedTools merging", () => {
@@ -176,129 +134,19 @@ describe("parseSdkOptions", () => {
});
});
describe("mcp-config merging", () => {
test("should pass through single mcp-config in extraArgs", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}'`,
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
'{"mcpServers":{"server1":{"command":"cmd1"}}}',
);
});
test("should merge multiple mcp-config flags with inline JSON", () => {
// Simulates action prepending its config, then user providing their own
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node","args":["server.js"]}}}' --mcp-config '{"mcpServers":{"user_server":{"command":"custom","args":["run"]}}}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
expect(mcpConfig.mcpServers).toHaveProperty("user_server");
expect(mcpConfig.mcpServers.github_comment.command).toBe("node");
expect(mcpConfig.mcpServers.user_server.command).toBe("custom");
});
test("should merge three mcp-config flags", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}' --mcp-config '{"mcpServers":{"server2":{"command":"cmd2"}}}' --mcp-config '{"mcpServers":{"server3":{"command":"cmd3"}}}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("server1");
expect(mcpConfig.mcpServers).toHaveProperty("server2");
expect(mcpConfig.mcpServers).toHaveProperty("server3");
});
test("should handle mcp-config file path when no inline JSON exists", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config /tmp/user-mcp-config.json`,
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
"/tmp/user-mcp-config.json",
);
});
test("should merge inline JSON configs when file path is also present", () => {
// When action provides inline JSON and user provides a file path,
// the inline JSON configs should be merged (file paths cannot be merged at parse time)
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config '{"mcpServers":{"github_ci":{"command":"node"}}}' --mcp-config /tmp/user-config.json`,
};
const result = parseSdkOptions(options);
// The inline JSON configs should be merged
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
});
test("should handle mcp-config with other flags", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{}}}' --model claude-3-5-sonnet --mcp-config '{"mcpServers":{"server2":{}}}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("server1");
expect(mcpConfig.mcpServers).toHaveProperty("server2");
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
});
test("should handle real-world scenario: action config + user config", () => {
// This is the exact scenario from the bug report
const actionConfig = JSON.stringify({
mcpServers: {
github_comment: {
command: "node",
args: ["github-comment-server.js"],
},
github_ci: { command: "node", args: ["github-ci-server.js"] },
},
});
const userConfig = JSON.stringify({
mcpServers: {
my_custom_server: { command: "python", args: ["server.py"] },
},
});
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '${actionConfig}' --mcp-config '${userConfig}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
// All servers should be present
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
expect(mcpConfig.mcpServers).toHaveProperty("my_custom_server");
});
});
describe("other extraArgs passthrough", () => {
test("should pass through mcp-config in extraArgs", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{}}' --allowedTools "Edit"`,
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
'{"mcpServers":{}}',
);
});
test("should pass through json-schema in extraArgs", () => {
const options: ClaudeOptions = {
claudeArgs: `--json-schema '{"type":"object"}'`,

View File

@@ -4,10 +4,7 @@ import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
import { writeFile, unlink } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import {
parseAndSetStructuredOutputs,
parseAndSetSessionId,
} from "../src/run-claude";
import { parseAndSetStructuredOutputs } from "../src/run-claude";
import * as core from "@actions/core";
// Mock execution file path
@@ -38,19 +35,16 @@ async function createMockExecutionFile(
// Spy on core functions
let setOutputSpy: any;
let infoSpy: any;
let warningSpy: any;
beforeEach(() => {
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
infoSpy = spyOn(core, "info").mockImplementation(() => {});
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
});
describe("parseAndSetStructuredOutputs", () => {
afterEach(async () => {
setOutputSpy?.mockRestore();
infoSpy?.mockRestore();
warningSpy?.mockRestore();
try {
await unlink(TEST_EXECUTION_FILE);
} catch {
@@ -162,66 +156,3 @@ describe("parseAndSetStructuredOutputs", () => {
);
});
});
describe("parseAndSetSessionId", () => {
afterEach(async () => {
setOutputSpy?.mockRestore();
infoSpy?.mockRestore();
warningSpy?.mockRestore();
try {
await unlink(TEST_EXECUTION_FILE);
} catch {
// Ignore if file doesn't exist
}
});
test("should extract session_id from system.init message", async () => {
const messages = [
{ type: "system", subtype: "init", session_id: "test-session-123" },
{ type: "result", cost_usd: 0.01 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
});
test("should handle missing session_id gracefully", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "result", cost_usd: 0.01 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
});
test("should handle missing system.init message gracefully", async () => {
const messages = [{ type: "result", cost_usd: 0.01 }];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
});
test("should handle malformed JSON gracefully with warning", async () => {
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
expect(warningSpy).toHaveBeenCalled();
});
test("should handle non-existent file gracefully with warning", async () => {
await parseAndSetSessionId("/nonexistent/file.json");
expect(setOutputSpy).not.toHaveBeenCalled();
expect(warningSpy).toHaveBeenCalled();
});
});

View File

@@ -1,13 +1,12 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@anthropic-ai/claude-code-action",
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
"@anthropic-ai/claude-agent-sdk": "^0.1.52",
"@modelcontextprotocol/sdk": "^1.11.0",
"@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1",
@@ -37,7 +36,7 @@
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.74", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1 || ^4.0.0" } }, "sha512-d6H3Oo625WAG3BrBFKJsuSshi4f0amc0kTJTm83LRPPFxn9kfq58FX4Oxxt+RUD9N3QumW9sQSEDnri20/F4qQ=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.52", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-yF8N05+9NRbqYA/h39jQ726HTQFrdXXp7pEfDNKIJ2c4FdWvEjxBA/8ciZIebN6/PyvGDcbEp3yq2Co4rNpg6A=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],

View File

@@ -7,7 +7,7 @@ You can authenticate with Claude using any of these four methods:
3. Google Vertex AI with OIDC authentication
4. Microsoft Foundry with OIDC authentication
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://code.claude.com/docs/en/github-actions#for-aws-bedrock:).
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions#using-with-aws-bedrock-%26-google-vertex-ai).
**Note**:

View File

@@ -12,7 +12,7 @@
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
"@anthropic-ai/claude-agent-sdk": "^0.1.52",
"@modelcontextprotocol/sdk": "^1.11.0",
"@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1",

View File

@@ -841,78 +841,6 @@ f. If you are unable to complete certain steps, such as running a linter or test
return promptContent;
}
/**
* Result of generating prompt content
*/
export type PromptResult = {
promptContent: string;
allowedTools: string;
disallowedTools: string;
};
/**
* Generate prompt content and tool configurations.
* This function can be used directly without side effects (no file writes, no env vars).
*/
export function generatePromptContent(
mode: Mode,
modeContext: ModeContext,
githubData: FetchDataResult,
context: ParsedGitHubContext,
): PromptResult {
// Prepare the context for prompt generation
let claudeCommentId: string = "";
if (mode.name === "tag") {
if (!modeContext.commentId) {
throw new Error(
`${mode.name} mode requires a comment ID for prompt generation`,
);
}
claudeCommentId = modeContext.commentId.toString();
}
const preparedContext = prepareContext(
context,
claudeCommentId,
modeContext.baseBranch,
modeContext.claudeBranch,
);
// Generate the prompt directly
const promptContent = generatePrompt(
preparedContext,
githubData,
context.inputs.useCommitSigning,
mode,
);
// Get mode-specific tools
const modeAllowedTools = mode.getAllowedTools();
const modeDisallowedTools = mode.getDisallowedTools();
const hasActionsReadPermission = false;
const allowedTools = buildAllowedToolsString(
modeAllowedTools,
hasActionsReadPermission,
context.inputs.useCommitSigning,
);
const disallowedTools = buildDisallowedToolsString(
modeDisallowedTools,
modeAllowedTools,
);
return {
promptContent,
allowedTools,
disallowedTools,
};
}
/**
* Create prompt and write to file.
* This is the legacy function that writes files and sets environment variables.
* For the unified step, use generatePromptContent() instead.
*/
export async function createPrompt(
mode: Mode,
modeContext: ModeContext,
@@ -920,30 +848,66 @@ export async function createPrompt(
context: ParsedGitHubContext,
) {
try {
const result = generatePromptContent(
mode,
modeContext,
githubData,
// Prepare the context for prompt generation
let claudeCommentId: string = "";
if (mode.name === "tag") {
if (!modeContext.commentId) {
throw new Error(
`${mode.name} mode requires a comment ID for prompt generation`,
);
}
claudeCommentId = modeContext.commentId.toString();
}
const preparedContext = prepareContext(
context,
claudeCommentId,
modeContext.baseBranch,
modeContext.claudeBranch,
);
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
recursive: true,
});
// Generate the prompt directly
const promptContent = generatePrompt(
preparedContext,
githubData,
context.inputs.useCommitSigning,
mode,
);
// Log the final prompt to console
console.log("===== FINAL PROMPT =====");
console.log(result.promptContent);
console.log(promptContent);
console.log("=======================");
// Write the prompt file
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
recursive: true,
});
await writeFile(
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
result.promptContent,
promptContent,
);
// Set environment variables
core.exportVariable("ALLOWED_TOOLS", result.allowedTools);
core.exportVariable("DISALLOWED_TOOLS", result.disallowedTools);
// Set allowed tools
const hasActionsReadPermission = false;
// Get mode-specific tools
const modeAllowedTools = mode.getAllowedTools();
const modeDisallowedTools = mode.getDisallowedTools();
const allAllowedTools = buildAllowedToolsString(
modeAllowedTools,
hasActionsReadPermission,
context.inputs.useCommitSigning,
);
const allDisallowedTools = buildDisallowedToolsString(
modeDisallowedTools,
modeAllowedTools,
);
core.exportVariable("ALLOWED_TOOLS", allAllowedTools);
core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools);
} catch (error) {
core.setFailed(`Create prompt failed with error: ${error}`);
process.exit(1);

View File

@@ -1,186 +0,0 @@
#!/usr/bin/env bun
/**
* Unified entry point for the Claude action.
*
* This combines the prepare and run phases into a single step,
* passing data directly in-memory instead of via files and outputs.
*/
import * as core from "@actions/core";
import { setupGitHubToken } from "../github/token";
import { checkWritePermissions } from "../github/validation/permissions";
import { createOctokit } from "../github/api/client";
import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode } from "../modes/registry";
import { prepare } from "../prepare";
import { collectActionInputsPresence } from "./collect-inputs";
import {
runClaudeWithSdk,
setupClaudeCodeSettings,
installPlugins,
parseSdkOptions,
} from "../../base-action/src/lib";
async function run() {
try {
// ============================================
// PHASE 1: PREPARE
// ============================================
collectActionInputsPresence();
// Parse GitHub context first to enable mode detection
const context = parseGitHubContext();
// Auto-detect mode based on context
const mode = getMode(context);
// Setup GitHub token
const githubToken = await setupGitHubToken();
const octokit = createOctokit(githubToken);
// Check write permissions (only for entity contexts)
if (isEntityContext(context)) {
const githubTokenProvided = !!process.env.OVERRIDE_GITHUB_TOKEN;
const hasWritePermissions = await checkWritePermissions(
octokit.rest,
context,
context.inputs.allowedNonWriteUsers,
githubTokenProvided,
);
if (!hasWritePermissions) {
throw new Error(
"Actor does not have write permissions to the repository",
);
}
}
// Check trigger conditions
const containsTrigger = mode.shouldTrigger(context);
// Debug logging
console.log(`Mode: ${mode.name}`);
console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`);
console.log(`Trigger result: ${containsTrigger}`);
// Set output for action.yml to check
core.setOutput("contains_trigger", containsTrigger.toString());
if (!containsTrigger) {
console.log("No trigger found, skipping remaining steps");
core.setOutput("GITHUB_TOKEN", githubToken);
return;
}
// Run mode.prepare() - returns prompt, commentId, branchInfo, etc.
const prepareResult = await prepare({
context,
octokit,
mode,
githubToken,
});
// Set outputs that may be needed by subsequent steps
core.setOutput("GITHUB_TOKEN", githubToken);
if (prepareResult.commentId) {
core.setOutput("claude_comment_id", prepareResult.commentId.toString());
}
core.setOutput(
"CLAUDE_BRANCH",
prepareResult.branchInfo.claudeBranch || "",
);
core.setOutput("BASE_BRANCH", prepareResult.branchInfo.baseBranch);
// Get system prompt from mode if available
let appendSystemPrompt: string | undefined;
if (mode.getSystemPrompt) {
const modeContext = mode.prepareContext(context, {
commentId: prepareResult.commentId,
baseBranch: prepareResult.branchInfo.baseBranch,
claudeBranch: prepareResult.branchInfo.claudeBranch,
});
appendSystemPrompt = mode.getSystemPrompt(modeContext);
}
// ============================================
// PHASE 2: SETUP
// ============================================
// Setup Claude Code settings
await setupClaudeCodeSettings(
process.env.INPUT_SETTINGS,
undefined, // homeDir
);
// 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,
);
// ============================================
// PHASE 3: EXECUTE
// ============================================
// Get prompt content from prepare result
const promptContent = prepareResult.promptContent;
if (!promptContent) {
throw new Error("No prompt content generated by prepare phase");
}
console.log("===== PROMPT CONTENT =====");
console.log(`Prompt length: ${promptContent.length} chars`);
console.log("==========================");
// Build SDK options from environment and prepare result
const sdkOptions = parseSdkOptions({
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
allowedTools:
prepareResult.allowedTools || process.env.INPUT_ALLOWED_TOOLS,
disallowedTools:
prepareResult.disallowedTools || process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS,
mcpConfig: process.env.INPUT_MCP_CONFIG,
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
appendSystemPrompt:
appendSystemPrompt || process.env.INPUT_APPEND_SYSTEM_PROMPT,
claudeEnv: process.env.INPUT_CLAUDE_ENV,
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL,
pathToClaudeCodeExecutable:
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
});
// Run Claude with prompt string directly
const execResult = await runClaudeWithSdk(
{ type: "string", prompt: promptContent },
sdkOptions,
{ setOutputs: true },
);
// Set additional outputs
core.setOutput("execution_file", execResult.executionFile);
core.setOutput("conclusion", execResult.conclusion);
if (execResult.structuredOutput) {
core.setOutput("structured_output", execResult.structuredOutput);
}
if (!execResult.success) {
core.setFailed(`Claude execution failed: ${execResult.error}`);
process.exit(1);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
core.setFailed(`Run step failed with error: ${errorMessage}`);
core.setOutput("prepare_error", errorMessage);
core.setOutput("conclusion", "failure");
process.exit(1);
}
}
if (import.meta.main) {
run();
}

View File

@@ -6,112 +6,13 @@
* - For Issues: Create a new branch
*/
import { execFileSync } from "child_process";
import { $ } from "bun";
import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client";
import type { FetchDataResult } from "../data/fetcher";
/**
* Validates a git branch name against a strict whitelist pattern.
* This prevents command injection by ensuring only safe characters are used.
*
* Valid branch names:
* - Start with alphanumeric character (not dash, to prevent option injection)
* - Contain only alphanumeric, forward slash, hyphen, underscore, or period
* - Do not start or end with a period
* - Do not end with a slash
* - Do not contain '..' (path traversal)
* - Do not contain '//' (consecutive slashes)
* - Do not end with '.lock'
* - Do not contain '@{'
* - Do not contain control characters or special git characters (~^:?*[\])
*/
export function validateBranchName(branchName: string): void {
// Check for empty or whitespace-only names
if (!branchName || branchName.trim().length === 0) {
throw new Error("Branch name cannot be empty");
}
// Check for leading dash (prevents option injection like --help, -x)
if (branchName.startsWith("-")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot start with a dash.`,
);
}
// Check for control characters and special git characters (~^:?*[\])
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F ~^:?*[\]\\]/.test(branchName)) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain control characters, spaces, or special git characters (~^:?*[\\]).`,
);
}
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/;
if (!validPattern.test(branchName)) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, or periods.`,
);
}
// Check for leading/trailing periods
if (branchName.startsWith(".") || branchName.endsWith(".")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot start or end with a period.`,
);
}
// Check for trailing slash
if (branchName.endsWith("/")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot end with a slash.`,
);
}
// Check for consecutive slashes
if (branchName.includes("//")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain consecutive slashes.`,
);
}
// Additional git-specific validations
if (branchName.includes("..")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain '..'`,
);
}
if (branchName.endsWith(".lock")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot end with '.lock'`,
);
}
if (branchName.includes("@{")) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
);
}
}
/**
* Executes a git command safely using execFileSync to avoid shell interpolation.
*
* Security: execFileSync passes arguments directly to the git binary without
* invoking a shell, preventing command injection attacks where malicious input
* could be interpreted as shell commands (e.g., branch names containing `;`, `|`, `&&`).
*
* @param args - Git command arguments (e.g., ["checkout", "branch-name"])
*/
function execGit(args: string[]): void {
execFileSync("git", args, { stdio: "inherit" });
}
export type BranchInfo = {
baseBranch: string;
claudeBranch?: string;
@@ -152,19 +53,14 @@ export async function setupBranch(
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
);
// Validate branch names before use to prevent command injection
validateBranchName(branchName);
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
// Using execFileSync instead of shell template literals for security
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
execGit(["checkout", branchName, "--"]);
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
await $`git checkout ${branchName} --`;
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
// For open PRs, we need to get the base branch of the PR
const baseBranch = prData.baseRefName;
validateBranchName(baseBranch);
return {
baseBranch,
@@ -222,9 +118,8 @@ export async function setupBranch(
// Ensure we're on the source branch
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
validateBranchName(sourceBranch);
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
execGit(["checkout", sourceBranch, "--"]);
await $`git fetch origin ${sourceBranch} --depth=1`;
await $`git checkout ${sourceBranch}`;
// Set outputs for GitHub Actions
core.setOutput("CLAUDE_BRANCH", newBranch);
@@ -243,13 +138,11 @@ export async function setupBranch(
// Fetch and checkout the source branch first to ensure we branch from the correct base
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
validateBranchName(sourceBranch);
validateBranchName(newBranch);
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
execGit(["checkout", sourceBranch, "--"]);
await $`git fetch origin ${sourceBranch} --depth=1`;
await $`git checkout ${sourceBranch}`;
// Create and checkout the new branch from the source branch
execGit(["checkout", "-b", newBranch]);
await $`git checkout -b ${newBranch}`;
console.log(
`Successfully created and checked out local branch: ${newBranch}`,

View File

@@ -95,15 +95,16 @@ export const agentMode: Mode = {
}
}
// Generate prompt content - use the user's prompt directly
// Create prompt directory
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
recursive: true,
});
// Write the prompt file - use the user's prompt directly
const promptContent =
context.inputs.prompt ||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
// Also write file for backwards compatibility with current flow
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
recursive: true,
});
await writeFile(
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
promptContent,
@@ -161,8 +162,6 @@ export const agentMode: Mode = {
claudeBranch: claudeBranch,
},
mcpConfig: ourMcpConfig,
promptContent,
// Agent mode doesn't use the same allowed/disallowed tools mechanism as tag mode
};
},

View File

@@ -10,11 +10,7 @@ import {
fetchGitHubData,
extractTriggerTimestamp,
} from "../../github/data/fetcher";
import {
createPrompt,
generateDefaultPrompt,
generatePromptContent,
} from "../../create-prompt";
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
import { isEntityContext } from "../../github/context";
import type { PreparedContext } from "../../create-prompt/types";
import type { FetchDataResult } from "../../github/data/fetcher";
@@ -108,22 +104,13 @@ export const tagMode: Mode = {
}
}
// Create prompt
// Create prompt file
const modeContext = this.prepareContext(context, {
commentId,
baseBranch: branchInfo.baseBranch,
claudeBranch: branchInfo.claudeBranch,
});
// Generate prompt content - returns data instead of writing file
const promptResult = generatePromptContent(
tagMode,
modeContext,
githubData,
context,
);
// Also write file for backwards compatibility with current flow
await createPrompt(tagMode, modeContext, githubData, context);
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
@@ -201,9 +188,6 @@ export const tagMode: Mode = {
commentId,
branchInfo,
mcpConfig: ourMcpConfig,
promptContent: promptResult.promptContent,
allowedTools: promptResult.allowedTools,
disallowedTools: promptResult.disallowedTools,
};
},

View File

@@ -97,10 +97,4 @@ export type ModeResult = {
currentBranch: string;
};
mcpConfig: string;
/** Generated prompt content for Claude */
promptContent?: string;
/** Comma-separated list of allowed tools */
allowedTools?: string;
/** Comma-separated list of disallowed tools */
disallowedTools?: string;
};

View File

@@ -10,12 +10,6 @@ export type PrepareResult = {
currentBranch: string;
};
mcpConfig: string;
/** Generated prompt content for Claude */
promptContent?: string;
/** Comma-separated list of allowed tools */
allowedTools?: string;
/** Comma-separated list of disallowed tools */
disallowedTools?: string;
};
export type PrepareOptions = {

View File

@@ -177,7 +177,6 @@ describe("Agent Mode", () => {
claudeBranch: undefined,
},
mcpConfig: expect.any(String),
promptContent: expect.any(String),
});
// Clean up

View File

@@ -1,201 +0,0 @@
import { describe, expect, it } from "bun:test";
import { validateBranchName } from "../src/github/operations/branch";
describe("validateBranchName", () => {
describe("valid branch names", () => {
it("should accept simple alphanumeric names", () => {
expect(() => validateBranchName("main")).not.toThrow();
expect(() => validateBranchName("feature123")).not.toThrow();
expect(() => validateBranchName("Branch1")).not.toThrow();
});
it("should accept names with hyphens", () => {
expect(() => validateBranchName("feature-branch")).not.toThrow();
expect(() => validateBranchName("fix-bug-123")).not.toThrow();
});
it("should accept names with underscores", () => {
expect(() => validateBranchName("feature_branch")).not.toThrow();
expect(() => validateBranchName("fix_bug_123")).not.toThrow();
});
it("should accept names with forward slashes", () => {
expect(() => validateBranchName("feature/new-thing")).not.toThrow();
expect(() => validateBranchName("user/feature/branch")).not.toThrow();
});
it("should accept names with periods", () => {
expect(() => validateBranchName("v1.0.0")).not.toThrow();
expect(() => validateBranchName("release.1.2.3")).not.toThrow();
});
it("should accept typical branch name formats", () => {
expect(() =>
validateBranchName("claude/issue-123-20250101-1234"),
).not.toThrow();
expect(() => validateBranchName("refs/heads/main")).not.toThrow();
expect(() => validateBranchName("bugfix/JIRA-1234")).not.toThrow();
});
});
describe("command injection attempts", () => {
it("should reject shell command substitution with $()", () => {
expect(() => validateBranchName("$(whoami)")).toThrow();
expect(() => validateBranchName("branch-$(rm -rf /)")).toThrow();
expect(() => validateBranchName("test$(cat /etc/passwd)")).toThrow();
});
it("should reject shell command substitution with backticks", () => {
expect(() => validateBranchName("`whoami`")).toThrow();
expect(() => validateBranchName("branch-`rm -rf /`")).toThrow();
});
it("should reject command chaining with semicolons", () => {
expect(() => validateBranchName("branch; rm -rf /")).toThrow();
expect(() => validateBranchName("test;whoami")).toThrow();
});
it("should reject command chaining with &&", () => {
expect(() => validateBranchName("branch && rm -rf /")).toThrow();
expect(() => validateBranchName("test&&whoami")).toThrow();
});
it("should reject command chaining with ||", () => {
expect(() => validateBranchName("branch || rm -rf /")).toThrow();
expect(() => validateBranchName("test||whoami")).toThrow();
});
it("should reject pipe characters", () => {
expect(() => validateBranchName("branch | cat")).toThrow();
expect(() => validateBranchName("test|grep password")).toThrow();
});
it("should reject redirection operators", () => {
expect(() => validateBranchName("branch > /etc/passwd")).toThrow();
expect(() => validateBranchName("branch < input")).toThrow();
expect(() => validateBranchName("branch >> file")).toThrow();
});
});
describe("option injection attempts", () => {
it("should reject branch names starting with dash", () => {
expect(() => validateBranchName("-x")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("--help")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("-")).toThrow(/cannot start with a dash/);
expect(() => validateBranchName("--version")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("-rf")).toThrow(
/cannot start with a dash/,
);
});
});
describe("path traversal attempts", () => {
it("should reject double dot sequences", () => {
expect(() => validateBranchName("../../../etc")).toThrow();
expect(() => validateBranchName("branch/../secret")).toThrow(/'\.\.'$/);
expect(() => validateBranchName("a..b")).toThrow(/'\.\.'$/);
});
});
describe("git-specific invalid patterns", () => {
it("should reject @{ sequence", () => {
expect(() => validateBranchName("branch@{1}")).toThrow(/@{/);
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
});
it("should reject .lock suffix", () => {
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
});
it("should reject consecutive slashes", () => {
expect(() => validateBranchName("feature//branch")).toThrow(
/consecutive slashes/,
);
expect(() => validateBranchName("a//b//c")).toThrow(
/consecutive slashes/,
);
});
it("should reject trailing slashes", () => {
expect(() => validateBranchName("feature/")).toThrow(
/cannot end with a slash/,
);
expect(() => validateBranchName("branch/")).toThrow(
/cannot end with a slash/,
);
});
it("should reject leading periods", () => {
expect(() => validateBranchName(".hidden")).toThrow();
});
it("should reject trailing periods", () => {
expect(() => validateBranchName("branch.")).toThrow(
/cannot start or end with a period/,
);
});
it("should reject special git refspec characters", () => {
expect(() => validateBranchName("branch~1")).toThrow();
expect(() => validateBranchName("branch^2")).toThrow();
expect(() => validateBranchName("branch:ref")).toThrow();
expect(() => validateBranchName("branch?")).toThrow();
expect(() => validateBranchName("branch*")).toThrow();
expect(() => validateBranchName("branch[0]")).toThrow();
expect(() => validateBranchName("branch\\path")).toThrow();
});
});
describe("control characters and special characters", () => {
it("should reject null bytes", () => {
expect(() => validateBranchName("branch\x00name")).toThrow();
});
it("should reject other control characters", () => {
expect(() => validateBranchName("branch\x01name")).toThrow();
expect(() => validateBranchName("branch\x1Fname")).toThrow();
expect(() => validateBranchName("branch\x7Fname")).toThrow();
});
it("should reject spaces", () => {
expect(() => validateBranchName("branch name")).toThrow();
expect(() => validateBranchName("feature branch")).toThrow();
});
it("should reject newlines and tabs", () => {
expect(() => validateBranchName("branch\nname")).toThrow();
expect(() => validateBranchName("branch\tname")).toThrow();
});
});
describe("empty and whitespace", () => {
it("should reject empty strings", () => {
expect(() => validateBranchName("")).toThrow(/cannot be empty/);
});
it("should reject whitespace-only strings", () => {
expect(() => validateBranchName(" ")).toThrow();
expect(() => validateBranchName("\t\n")).toThrow();
});
});
describe("edge cases", () => {
it("should accept single alphanumeric character", () => {
expect(() => validateBranchName("a")).not.toThrow();
expect(() => validateBranchName("1")).not.toThrow();
});
it("should reject single special characters", () => {
expect(() => validateBranchName(".")).toThrow();
expect(() => validateBranchName("/")).toThrow();
expect(() => validateBranchName("-")).toThrow();
});
});
});