mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
1 Commits
v1.0.28
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e8fdc051 |
123
action.yml
123
action.yml
@@ -81,10 +81,6 @@ inputs:
|
|||||||
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
ssh_signing_key:
|
|
||||||
description: "SSH private key for signing commits. When provided, git will be configured to use SSH signing. Takes precedence over use_commit_signing."
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
bot_id:
|
bot_id:
|
||||||
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
|
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
|
||||||
required: false
|
required: false
|
||||||
@@ -97,10 +93,6 @@ inputs:
|
|||||||
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
|
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
include_fix_links:
|
|
||||||
description: "Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue"
|
|
||||||
required: false
|
|
||||||
default: "true"
|
|
||||||
path_to_claude_code_executable:
|
path_to_claude_code_executable:
|
||||||
description: "Optional path to a custom Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: Using an older version may cause problems if the action begins taking advantage of new Claude Code 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 Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: Using an older version may cause problems if the action begins taking advantage of new Claude Code features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
@@ -128,10 +120,10 @@ outputs:
|
|||||||
value: ${{ steps.claude-code.outputs.execution_file }}
|
value: ${{ steps.claude-code.outputs.execution_file }}
|
||||||
branch_name:
|
branch_name:
|
||||||
description: "The branch created by Claude Code for this execution"
|
description: "The branch created by Claude Code for this execution"
|
||||||
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
value: ${{ steps.claude-code.outputs.CLAUDE_BRANCH }}
|
||||||
github_token:
|
github_token:
|
||||||
description: "The GitHub token used by the action (Claude App token if available)"
|
description: "The GitHub token used by the action (Claude App token if available)"
|
||||||
value: ${{ steps.prepare.outputs.github_token }}
|
value: ${{ steps.claude-code.outputs.GITHUB_TOKEN }}
|
||||||
structured_output:
|
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"
|
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 }}
|
value: ${{ steps.claude-code.outputs.structured_output }}
|
||||||
@@ -160,46 +152,15 @@ runs:
|
|||||||
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd ${GITHUB_ACTION_PATH}
|
|
||||||
bun install
|
|
||||||
|
|
||||||
- name: Prepare action
|
|
||||||
id: prepare
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
|
|
||||||
env:
|
|
||||||
MODE: ${{ inputs.mode }}
|
|
||||||
PROMPT: ${{ inputs.prompt }}
|
|
||||||
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
|
||||||
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
|
||||||
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
|
||||||
BASE_BRANCH: ${{ inputs.base_branch }}
|
|
||||||
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
|
||||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
|
||||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
|
||||||
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
|
||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
|
||||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
|
||||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
|
||||||
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
|
|
||||||
BOT_ID: ${{ inputs.bot_id }}
|
|
||||||
BOT_NAME: ${{ inputs.bot_name }}
|
|
||||||
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
|
||||||
INCLUDE_FIX_LINKS: ${{ inputs.include_fix_links }}
|
|
||||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
|
||||||
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
|
||||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
|
||||||
|
|
||||||
- name: Install Base Action Dependencies
|
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
|
# Install main action dependencies
|
||||||
|
cd ${GITHUB_ACTION_PATH}
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Install base-action dependencies
|
||||||
echo "Installing base-action dependencies..."
|
echo "Installing base-action dependencies..."
|
||||||
cd ${GITHUB_ACTION_PATH}/base-action
|
cd ${GITHUB_ACTION_PATH}/base-action
|
||||||
bun install
|
bun install
|
||||||
@@ -208,13 +169,12 @@ runs:
|
|||||||
|
|
||||||
# Install Claude Code if no custom executable is provided
|
# Install Claude Code if no custom executable is provided
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.0.76"
|
CLAUDE_CODE_VERSION="2.0.74"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
if command -v timeout &> /dev/null; then
|
if command -v timeout &> /dev/null; then
|
||||||
# Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails
|
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
||||||
timeout --foreground --kill-after=10 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
|
||||||
else
|
else
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
||||||
fi
|
fi
|
||||||
@@ -234,31 +194,46 @@ runs:
|
|||||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Unified step: prepare + setup + run Claude
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
|
||||||
# Run the base-action
|
|
||||||
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
|
|
||||||
env:
|
env:
|
||||||
# Base-action inputs
|
# Action configuration
|
||||||
CLAUDE_CODE_ACTION: "1"
|
CLAUDE_CODE_ACTION: "1"
|
||||||
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
MODE: ${{ inputs.mode }}
|
||||||
|
PROMPT: ${{ inputs.prompt }}
|
||||||
|
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||||
|
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||||
|
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
||||||
|
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||||
|
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
||||||
|
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||||
|
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||||
|
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
||||||
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||||
|
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||||
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
|
BOT_ID: ${{ inputs.bot_id }}
|
||||||
|
BOT_NAME: ${{ inputs.bot_name }}
|
||||||
|
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
||||||
|
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||||
|
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
|
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||||
|
|
||||||
|
# Base-action inputs
|
||||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||||
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
|
INPUT_CLAUDE_ARGS: ${{ inputs.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_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_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
||||||
|
|
||||||
# Model configuration
|
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
|
||||||
GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
|
||||||
NODE_VERSION: ${{ env.NODE_VERSION }}
|
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||||
DETAILED_PERMISSION_MESSAGES: "1"
|
DETAILED_PERMISSION_MESSAGES: "1"
|
||||||
|
|
||||||
@@ -298,33 +273,33 @@ runs:
|
|||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
||||||
|
|
||||||
- name: Update comment with job link
|
- name: Update comment with job link
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
if: steps.claude-code.outputs.contains_trigger == 'true' && steps.claude-code.outputs.claude_comment_id && always()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts
|
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts
|
||||||
env:
|
env:
|
||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||||
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }}
|
CLAUDE_COMMENT_ID: ${{ steps.claude-code.outputs.claude_comment_id }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.claude-code.outputs.GITHUB_TOKEN }}
|
||||||
GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ steps.claude-code.outputs.GITHUB_TOKEN }}
|
||||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||||
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
|
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
|
||||||
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
CLAUDE_BRANCH: ${{ steps.claude-code.outputs.CLAUDE_BRANCH }}
|
||||||
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
|
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
|
||||||
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
|
BASE_BRANCH: ${{ steps.claude-code.outputs.BASE_BRANCH }}
|
||||||
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
||||||
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
|
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 || '' }}
|
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.prepare.outcome == 'success' }}
|
PREPARE_SUCCESS: ${{ steps.claude-code.outcome == 'success' }}
|
||||||
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
PREPARE_ERROR: ${{ steps.claude-code.outputs.prepare_error || '' }}
|
||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
||||||
|
|
||||||
- name: Display Claude Code Report
|
- name: Display Claude Code Report
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
if: steps.claude-code.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# Try to format the turns, but if it fails, dump the raw JSON
|
# Try to format the turns, but if it fails, dump the raw JSON
|
||||||
@@ -340,19 +315,13 @@ runs:
|
|||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Cleanup SSH signing key
|
|
||||||
if: always() && inputs.ssh_signing_key != ''
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
|
|
||||||
|
|
||||||
- name: Revoke app token
|
- name: Revoke app token
|
||||||
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
if: always() && inputs.github_token == '' && steps.claude-code.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L \
|
curl -L \
|
||||||
-X DELETE \
|
-X DELETE \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \
|
-H "Authorization: Bearer ${{ steps.claude-code.outputs.GITHUB_TOKEN }}" \
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
${GITHUB_API_URL:-https://api.github.com}/installation/token
|
${GITHUB_API_URL:-https://api.github.com}/installation/token
|
||||||
|
|||||||
@@ -124,13 +124,12 @@ runs:
|
|||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.0.76"
|
CLAUDE_CODE_VERSION="2.0.74"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
if command -v timeout &> /dev/null; then
|
if command -v timeout &> /dev/null; then
|
||||||
# Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails
|
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
||||||
timeout --foreground --kill-after=10 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
|
||||||
else
|
else
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"name": "@anthropic-ai/claude-code-base-action",
|
"name": "@anthropic-ai/claude-code-base-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@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.76", "", { "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-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw=="],
|
"@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=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -8,47 +8,26 @@ const MARKETPLACE_URL_REGEX =
|
|||||||
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
|
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a marketplace input is a local path (not a URL)
|
* Validates a marketplace URL for security issues
|
||||||
* @param input - The marketplace input to check
|
* @param url - The marketplace URL to validate
|
||||||
* @returns true if the input is a local path, false if it's a URL
|
* @throws {Error} If the URL is invalid
|
||||||
*/
|
*/
|
||||||
function isLocalPath(input: string): boolean {
|
function validateMarketplaceUrl(url: string): void {
|
||||||
// Local paths start with ./, ../, /, or a drive letter (Windows)
|
const normalized = url.trim();
|
||||||
return (
|
|
||||||
input.startsWith("./") ||
|
|
||||||
input.startsWith("../") ||
|
|
||||||
input.startsWith("/") ||
|
|
||||||
/^[a-zA-Z]:[\\\/]/.test(input)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a marketplace URL or local path
|
|
||||||
* @param input - The marketplace URL or local path to validate
|
|
||||||
* @throws {Error} If the input is invalid
|
|
||||||
*/
|
|
||||||
function validateMarketplaceInput(input: string): void {
|
|
||||||
const normalized = input.trim();
|
|
||||||
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new Error("Marketplace URL or path cannot be empty");
|
throw new Error("Marketplace URL cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local paths are passed directly to Claude Code which handles them
|
|
||||||
if (isLocalPath(normalized)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate as URL
|
|
||||||
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
|
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
|
||||||
throw new Error(`Invalid marketplace URL format: ${input}`);
|
throw new Error(`Invalid marketplace URL format: ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional check for valid URL structure
|
// Additional check for valid URL structure
|
||||||
try {
|
try {
|
||||||
new URL(normalized);
|
new URL(normalized);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Invalid marketplace URL: ${input}`);
|
throw new Error(`Invalid marketplace URL: ${url}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +55,9 @@ function validatePluginName(pluginName: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a newline-separated list of marketplace URLs or local paths and return an array of validated entries
|
* Parse a newline-separated list of marketplace URLs and return an array of validated URLs
|
||||||
* @param marketplaces - Newline-separated list of marketplace Git URLs or local paths
|
* @param marketplaces - Newline-separated list of marketplace Git URLs
|
||||||
* @returns Array of validated marketplace URLs or paths (empty array if none provided)
|
* @returns Array of validated marketplace URLs (empty array if none provided)
|
||||||
*/
|
*/
|
||||||
function parseMarketplaces(marketplaces?: string): string[] {
|
function parseMarketplaces(marketplaces?: string): string[] {
|
||||||
const trimmed = marketplaces?.trim();
|
const trimmed = marketplaces?.trim();
|
||||||
@@ -87,14 +66,14 @@ function parseMarketplaces(marketplaces?: string): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by newline and process each entry
|
// Split by newline and process each URL
|
||||||
return trimmed
|
return trimmed
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((entry) => entry.trim())
|
.map((url) => url.trim())
|
||||||
.filter((entry) => {
|
.filter((url) => {
|
||||||
if (entry.length === 0) return false;
|
if (url.length === 0) return false;
|
||||||
|
|
||||||
validateMarketplaceInput(entry);
|
validateMarketplaceUrl(url);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,26 +163,26 @@ async function installPlugin(
|
|||||||
/**
|
/**
|
||||||
* Adds a Claude Code plugin marketplace
|
* Adds a Claude Code plugin marketplace
|
||||||
* @param claudeExecutable - Path to the Claude executable
|
* @param claudeExecutable - Path to the Claude executable
|
||||||
* @param marketplace - The marketplace Git URL or local path to add
|
* @param marketplaceUrl - The marketplace Git URL to add
|
||||||
* @returns Promise that resolves when the marketplace add command completes
|
* @returns Promise that resolves when the marketplace add command completes
|
||||||
* @throws {Error} If the command fails to execute
|
* @throws {Error} If the command fails to execute
|
||||||
*/
|
*/
|
||||||
async function addMarketplace(
|
async function addMarketplace(
|
||||||
claudeExecutable: string,
|
claudeExecutable: string,
|
||||||
marketplace: string,
|
marketplaceUrl: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(`Adding marketplace: ${marketplace}`);
|
console.log(`Adding marketplace: ${marketplaceUrl}`);
|
||||||
|
|
||||||
return executeClaudeCommand(
|
return executeClaudeCommand(
|
||||||
claudeExecutable,
|
claudeExecutable,
|
||||||
["plugin", "marketplace", "add", marketplace],
|
["plugin", "marketplace", "add", marketplaceUrl],
|
||||||
`Failed to add marketplace '${marketplace}'`,
|
`Failed to add marketplace '${marketplaceUrl}'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs Claude Code plugins from a newline-separated list
|
* Installs Claude Code plugins from a newline-separated list
|
||||||
* @param marketplacesInput - Newline-separated list of marketplace Git URLs or local paths
|
* @param marketplacesInput - Newline-separated list of marketplace Git URLs
|
||||||
* @param pluginsInput - Newline-separated list of plugin names
|
* @param pluginsInput - Newline-separated list of plugin names
|
||||||
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
||||||
* @returns Promise that resolves when all plugins are installed
|
* @returns Promise that resolves when all plugins are installed
|
||||||
|
|||||||
13
base-action/src/lib.ts
Normal file
13
base-action/src/lib.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
@@ -212,8 +212,6 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||||
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||||
}
|
}
|
||||||
// Ensure SDK path uses the same entrypoint as the CLI path
|
|
||||||
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
|
|
||||||
|
|
||||||
// Build system prompt option - default to claude_code preset
|
// Build system prompt option - default to claude_code preset
|
||||||
let systemPrompt: SdkOptions["systemPrompt"];
|
let systemPrompt: SdkOptions["systemPrompt"];
|
||||||
|
|||||||
@@ -1,80 +1,25 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { readFile, writeFile, access } from "fs/promises";
|
import { readFile, writeFile } from "fs/promises";
|
||||||
import { dirname, join } from "path";
|
|
||||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type {
|
import type {
|
||||||
SDKMessage,
|
SDKMessage,
|
||||||
SDKResultMessage,
|
SDKResultMessage,
|
||||||
SDKUserMessage,
|
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
||||||
|
|
||||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||||
|
|
||||||
/** Filename for the user request file, written by prompt generation */
|
|
||||||
const USER_REQUEST_FILENAME = "claude-user-request.txt";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file exists
|
* Result of running Claude via SDK
|
||||||
*/
|
*/
|
||||||
async function fileExists(path: string): Promise<boolean> {
|
export type RunClaudeResult = {
|
||||||
try {
|
success: boolean;
|
||||||
await access(path);
|
executionFile: string;
|
||||||
return true;
|
conclusion: "success" | "failure";
|
||||||
} catch {
|
structuredOutput?: string;
|
||||||
return false;
|
sessionId?: string;
|
||||||
}
|
error?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a prompt configuration for the SDK.
|
|
||||||
* If a user request file exists alongside the prompt file, returns a multi-block
|
|
||||||
* SDKUserMessage that enables slash command processing in the CLI.
|
|
||||||
* Otherwise, returns the prompt as a simple string.
|
|
||||||
*/
|
|
||||||
async function createPromptConfig(
|
|
||||||
promptPath: string,
|
|
||||||
showFullOutput: boolean,
|
|
||||||
): Promise<string | AsyncIterable<SDKUserMessage>> {
|
|
||||||
const promptContent = await readFile(promptPath, "utf-8");
|
|
||||||
|
|
||||||
// Check for user request file in the same directory
|
|
||||||
const userRequestPath = join(dirname(promptPath), USER_REQUEST_FILENAME);
|
|
||||||
const hasUserRequest = await fileExists(userRequestPath);
|
|
||||||
|
|
||||||
if (!hasUserRequest) {
|
|
||||||
// No user request file - use simple string prompt
|
|
||||||
return promptContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User request file exists - create multi-block message
|
|
||||||
const userRequest = await readFile(userRequestPath, "utf-8");
|
|
||||||
if (showFullOutput) {
|
|
||||||
console.log("Using multi-block message with user request:", userRequest);
|
|
||||||
} else {
|
|
||||||
console.log("Using multi-block message with user request (content hidden)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an async generator that yields a single multi-block message
|
|
||||||
// The context/instructions go first, then the user's actual request last
|
|
||||||
// This allows the CLI to detect and process slash commands in the user request
|
|
||||||
async function* createMultiBlockMessage(): AsyncGenerator<SDKUserMessage> {
|
|
||||||
yield {
|
|
||||||
type: "user",
|
|
||||||
session_id: "",
|
|
||||||
message: {
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: promptContent }, // Instructions + GitHub context
|
|
||||||
{ type: "text", text: userRequest }, // User's request (may be a slash command)
|
|
||||||
],
|
|
||||||
},
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return createMultiBlockMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes SDK output to match CLI sanitization behavior
|
* Sanitizes SDK output to match CLI sanitization behavior
|
||||||
@@ -124,14 +69,31 @@ function sanitizeSdkOutput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run Claude using the Agent SDK
|
* 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
|
||||||
*/
|
*/
|
||||||
export async function runClaudeWithSdk(
|
export async function runClaudeWithSdk(
|
||||||
promptPath: string,
|
promptInput: PromptInput,
|
||||||
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
||||||
): Promise<void> {
|
{ setOutputs = true }: { setOutputs?: boolean } = {},
|
||||||
// Create prompt configuration - may be a string or multi-block message
|
): Promise<RunClaudeResult> {
|
||||||
const prompt = await createPromptConfig(promptPath, showFullOutput);
|
// Get prompt from string or file
|
||||||
|
const prompt =
|
||||||
|
promptInput.type === "string"
|
||||||
|
? promptInput.prompt
|
||||||
|
: await readFile(promptInput.promptPath, "utf-8");
|
||||||
|
|
||||||
if (!showFullOutput) {
|
if (!showFullOutput) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -142,7 +104,13 @@ export async function runClaudeWithSdk(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Running Claude with prompt from file: ${promptPath}`);
|
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)
|
// Log SDK options without env (which could contain sensitive data)
|
||||||
const { env, ...optionsToLog } = sdkOptions;
|
const { env, ...optionsToLog } = sdkOptions;
|
||||||
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
|
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
|
||||||
@@ -165,27 +133,47 @@ export async function runClaudeWithSdk(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("SDK execution error:", error);
|
console.error("SDK execution error:", error);
|
||||||
core.setOutput("conclusion", "failure");
|
if (setOutputs) {
|
||||||
process.exit(1);
|
core.setOutput("conclusion", "failure");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "failure",
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write execution file
|
// Write execution file
|
||||||
try {
|
try {
|
||||||
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
||||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
if (setOutputs) {
|
||||||
|
core.setOutput("execution_file", EXECUTION_FILE);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.warning(`Failed to write execution file: ${error}`);
|
core.warning(`Failed to write execution file: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resultMessage) {
|
if (!resultMessage) {
|
||||||
core.setOutput("conclusion", "failure");
|
if (setOutputs) {
|
||||||
|
core.setOutput("conclusion", "failure");
|
||||||
|
}
|
||||||
core.error("No result message received from Claude");
|
core.error("No result message received from Claude");
|
||||||
process.exit(1);
|
return {
|
||||||
|
success: false,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "failure",
|
||||||
|
error: "No result message received from Claude",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSuccess = resultMessage.subtype === "success";
|
const isSuccess = resultMessage.subtype === "success";
|
||||||
core.setOutput("conclusion", isSuccess ? "success" : "failure");
|
if (setOutputs) {
|
||||||
|
core.setOutput("conclusion", isSuccess ? "success" : "failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
let structuredOutput: string | undefined;
|
||||||
|
|
||||||
// Handle structured output
|
// Handle structured output
|
||||||
if (hasJsonSchema) {
|
if (hasJsonSchema) {
|
||||||
@@ -194,26 +182,64 @@ export async function runClaudeWithSdk(
|
|||||||
"structured_output" in resultMessage &&
|
"structured_output" in resultMessage &&
|
||||||
resultMessage.structured_output
|
resultMessage.structured_output
|
||||||
) {
|
) {
|
||||||
const structuredOutputJson = JSON.stringify(
|
structuredOutput = JSON.stringify(resultMessage.structured_output);
|
||||||
resultMessage.structured_output,
|
if (setOutputs) {
|
||||||
);
|
core.setOutput("structured_output", structuredOutput);
|
||||||
core.setOutput("structured_output", structuredOutputJson);
|
}
|
||||||
core.info(
|
core.info(
|
||||||
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
|
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
core.setFailed(
|
const errorMsg = `--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`;
|
||||||
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
|
if (setOutputs) {
|
||||||
);
|
core.setFailed(errorMsg);
|
||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
process.exit(1);
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "failure",
|
||||||
|
error: errorMsg,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
if ("errors" in resultMessage && resultMessage.errors) {
|
const errors =
|
||||||
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
"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) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { unlink, writeFile, stat, readFile } from "fs/promises";
|
|||||||
import { createWriteStream } from "fs";
|
import { createWriteStream } from "fs";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { parse as parseShellArgs } from "shell-quote";
|
import { parse as parseShellArgs } from "shell-quote";
|
||||||
import { runClaudeWithSdk } from "./run-claude-sdk";
|
import { runClaudeWithSdkFromFile } from "./run-claude-sdk";
|
||||||
import { parseSdkOptions } from "./parse-sdk-options";
|
import { parseSdkOptions } from "./parse-sdk-options";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -205,7 +205,7 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|||||||
|
|
||||||
if (useAgentSdk) {
|
if (useAgentSdk) {
|
||||||
const parsedOptions = parseSdkOptions(options);
|
const parsedOptions = parseSdkOptions(options);
|
||||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
return runClaudeWithSdkFromFile(promptPath, parsedOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = prepareRunConfig(promptPath, options);
|
const config = prepareRunConfig(promptPath, options);
|
||||||
|
|||||||
@@ -596,111 +596,4 @@ describe("installPlugins", () => {
|
|||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Local marketplace path tests
|
|
||||||
test("should accept local marketplace path with ./", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("./my-local-marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./my-local-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local marketplace path with absolute Unix path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("/home/user/my-marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "/home/user/my-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local marketplace path with Windows absolute path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("C:\\Users\\user\\marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "C:\\Users\\user\\marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept mixed local and remote marketplaces", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
"./local-marketplace\nhttps://github.com/user/remote.git",
|
|
||||||
"test-plugin",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./local-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "https://github.com/user/remote.git"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local path with ../ (parent directory)", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("../shared-plugins/marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "../shared-plugins/marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local path with nested directories", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("./plugins/my-org/my-marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./plugins/my-org/my-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local path with dots in directory name", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("./my.plugin.marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./my.plugin.marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -7,7 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@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.76", "", { "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-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw=="],
|
"@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=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -38,64 +38,7 @@ The following permissions are requested but not yet actively used. These will en
|
|||||||
|
|
||||||
## Commit Signing
|
## Commit Signing
|
||||||
|
|
||||||
By default, commits made by Claude are unsigned. You can enable commit signing using one of two methods:
|
Commits made by Claude through this action are no longer automatically signed with commit signatures. To enable commit signing set `use_commit_signing: True` in the workflow(s). This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
||||||
|
|
||||||
### Option 1: GitHub API Commit Signing (use_commit_signing)
|
|
||||||
|
|
||||||
This uses GitHub's API to create commits, which automatically signs them as verified from the GitHub App:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@main
|
|
||||||
with:
|
|
||||||
use_commit_signing: true
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the simplest option and requires no additional setup. However, because it uses the GitHub API instead of git CLI, it cannot perform complex git operations like rebasing, cherry-picking, or interactive history manipulation.
|
|
||||||
|
|
||||||
### Option 2: SSH Signing Key (ssh_signing_key)
|
|
||||||
|
|
||||||
This uses an SSH key to sign commits via git CLI. Use this option when you need both signed commits AND standard git operations (rebasing, cherry-picking, etc.):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@main
|
|
||||||
with:
|
|
||||||
ssh_signing_key: ${{ secrets.SSH_SIGNING_KEY }}
|
|
||||||
bot_id: "YOUR_GITHUB_USER_ID"
|
|
||||||
bot_name: "YOUR_GITHUB_USERNAME"
|
|
||||||
```
|
|
||||||
|
|
||||||
Commits will show as verified and attributed to the GitHub account that owns the signing key.
|
|
||||||
|
|
||||||
**Setup steps:**
|
|
||||||
|
|
||||||
1. Generate an SSH key pair for signing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh-keygen -t ed25519 -f ~/.ssh/signing_key -N "" -C "commit signing key"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Add the **public key** to your GitHub account:
|
|
||||||
|
|
||||||
- Go to GitHub → Settings → SSH and GPG keys
|
|
||||||
- Click "New SSH key"
|
|
||||||
- Select **Key type: Signing Key** (important)
|
|
||||||
- Paste the contents of `~/.ssh/signing_key.pub`
|
|
||||||
|
|
||||||
3. Add the **private key** to your repository secrets:
|
|
||||||
|
|
||||||
- Go to your repo → Settings → Secrets and variables → Actions
|
|
||||||
- Create a new secret named `SSH_SIGNING_KEY`
|
|
||||||
- Paste the contents of `~/.ssh/signing_key`
|
|
||||||
|
|
||||||
4. Get your GitHub user ID:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api users/YOUR_USERNAME --jq '.id'
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Update your workflow with `bot_id` and `bot_name` matching the account where you added the signing key.
|
|
||||||
|
|
||||||
**Note:** If both `ssh_signing_key` and `use_commit_signing` are provided, `ssh_signing_key` takes precedence.
|
|
||||||
|
|
||||||
## ⚠️ Authentication Protection
|
## ⚠️ Authentication Protection
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ jobs:
|
|||||||
| `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` |
|
||||||
| `include_fix_links` | Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue | No | `true` |
|
|
||||||
| `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](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (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` |
|
||||||
@@ -71,10 +70,9 @@ jobs:
|
|||||||
| `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 | "" |
|
||||||
| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` |
|
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||||
| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" |
|
| `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). Required with `ssh_signing_key` for verified commits | 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). Required with `ssh_signing_key` for verified commits | 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 | "" |
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
|
|||||||
@@ -21,12 +21,8 @@ import type { ParsedGitHubContext } from "../github/context";
|
|||||||
import type { CommonFields, PreparedContext, EventData } from "./types";
|
import type { CommonFields, PreparedContext, EventData } from "./types";
|
||||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||||
import type { Mode, ModeContext } from "../modes/types";
|
import type { Mode, ModeContext } from "../modes/types";
|
||||||
import { extractUserRequest } from "../utils/extract-user-request";
|
|
||||||
export type { CommonFields, PreparedContext } from "./types";
|
export type { CommonFields, PreparedContext } from "./types";
|
||||||
|
|
||||||
/** Filename for the user request file, read by the SDK runner */
|
|
||||||
const USER_REQUEST_FILENAME = "claude-user-request.txt";
|
|
||||||
|
|
||||||
// Tag mode defaults - these tools are needed for tag mode to function
|
// Tag mode defaults - these tools are needed for tag mode to function
|
||||||
const BASE_ALLOWED_TOOLS = [
|
const BASE_ALLOWED_TOOLS = [
|
||||||
"Edit",
|
"Edit",
|
||||||
@@ -738,13 +734,7 @@ ${eventData.eventName === "issue_comment" || eventData.eventName === "pull_reque
|
|||||||
- Reference specific code sections with file paths and line numbers${eventData.isPR ? `\n - AFTER reading files and analyzing code, you MUST call mcp__github_comment__update_claude_comment to post your review` : ""}
|
- Reference specific code sections with file paths and line numbers${eventData.isPR ? `\n - AFTER reading files and analyzing code, you MUST call mcp__github_comment__update_claude_comment to post your review` : ""}
|
||||||
- Formulate a concise, technical, and helpful response based on the context.
|
- Formulate a concise, technical, and helpful response based on the context.
|
||||||
- Reference specific code with inline formatting or code blocks.
|
- Reference specific code with inline formatting or code blocks.
|
||||||
- Include relevant file paths and line numbers when applicable.${
|
- Include relevant file paths and line numbers when applicable.
|
||||||
eventData.isPR && context.githubContext?.inputs.includeFixLinks
|
|
||||||
? `
|
|
||||||
- When identifying issues that could be fixed, include an inline link: [Fix this →](https://claude.ai/code?q=<URI_ENCODED_INSTRUCTIONS>&repo=${context.repository})
|
|
||||||
The query should be URI-encoded and include enough context for Claude Code to understand and fix the issue (file path, line numbers, branch name, what needs to change).`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
- ${eventData.isPR ? `IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_comment__update_claude_comment. This will be displayed as your PR review.` : `Remember that this feedback must be posted to the GitHub comment using mcp__github_comment__update_claude_comment.`}
|
- ${eventData.isPR ? `IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_comment__update_claude_comment. This will be displayed as your PR review.` : `Remember that this feedback must be posted to the GitHub comment using mcp__github_comment__update_claude_comment.`}
|
||||||
|
|
||||||
B. For Straightforward Changes:
|
B. For Straightforward Changes:
|
||||||
@@ -852,54 +842,77 @@ f. If you are unable to complete certain steps, such as running a linter or test
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the user's request from the prepared context and GitHub data.
|
* Result of generating prompt content
|
||||||
*
|
|
||||||
* This is used to send the user's actual command/request as a separate
|
|
||||||
* content block, enabling slash command processing in the CLI.
|
|
||||||
*
|
|
||||||
* @param context - The prepared context containing event data and trigger phrase
|
|
||||||
* @param githubData - The fetched GitHub data containing issue/PR body content
|
|
||||||
* @returns The extracted user request text (e.g., "/review-pr" or "fix this bug"),
|
|
||||||
* or null for assigned/labeled events without an explicit trigger in the body
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Comment event: "@claude /review-pr" -> returns "/review-pr"
|
|
||||||
* // Issue body with "@claude fix this" -> returns "fix this"
|
|
||||||
* // Issue assigned without @claude in body -> returns null
|
|
||||||
*/
|
*/
|
||||||
function extractUserRequestFromContext(
|
export type PromptResult = {
|
||||||
context: PreparedContext,
|
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,
|
githubData: FetchDataResult,
|
||||||
): string | null {
|
context: ParsedGitHubContext,
|
||||||
const { eventData, triggerPhrase } = context;
|
): PromptResult {
|
||||||
|
// Prepare the context for prompt generation
|
||||||
// For comment events, extract from comment body
|
let claudeCommentId: string = "";
|
||||||
if (
|
if (mode.name === "tag") {
|
||||||
"commentBody" in eventData &&
|
if (!modeContext.commentId) {
|
||||||
eventData.commentBody &&
|
throw new Error(
|
||||||
(eventData.eventName === "issue_comment" ||
|
`${mode.name} mode requires a comment ID for prompt generation`,
|
||||||
eventData.eventName === "pull_request_review_comment" ||
|
);
|
||||||
eventData.eventName === "pull_request_review")
|
|
||||||
) {
|
|
||||||
return extractUserRequest(eventData.commentBody, triggerPhrase);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For issue/PR events triggered by body content, extract from the body
|
|
||||||
if (githubData.contextData?.body) {
|
|
||||||
const request = extractUserRequest(
|
|
||||||
githubData.contextData.body,
|
|
||||||
triggerPhrase,
|
|
||||||
);
|
|
||||||
if (request) {
|
|
||||||
return request;
|
|
||||||
}
|
}
|
||||||
|
claudeCommentId = modeContext.commentId.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For assigned/labeled events without explicit trigger in body,
|
const preparedContext = prepareContext(
|
||||||
// return null to indicate the full context should be used
|
context,
|
||||||
return null;
|
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(
|
export async function createPrompt(
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
modeContext: ModeContext,
|
modeContext: ModeContext,
|
||||||
@@ -907,82 +920,30 @@ export async function createPrompt(
|
|||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Prepare the context for prompt generation
|
const result = generatePromptContent(
|
||||||
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,
|
mode,
|
||||||
|
modeContext,
|
||||||
|
githubData,
|
||||||
|
context,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the final prompt to console
|
// Log the final prompt to console
|
||||||
console.log("===== FINAL PROMPT =====");
|
console.log("===== FINAL PROMPT =====");
|
||||||
console.log(promptContent);
|
console.log(result.promptContent);
|
||||||
console.log("=======================");
|
console.log("=======================");
|
||||||
|
|
||||||
// Write the prompt file
|
// Write the prompt file
|
||||||
|
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
await writeFile(
|
await writeFile(
|
||||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||||
promptContent,
|
result.promptContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract and write the user request separately for SDK multi-block messaging
|
// Set environment variables
|
||||||
// This allows the CLI to process slash commands (e.g., "@claude /review-pr")
|
core.exportVariable("ALLOWED_TOOLS", result.allowedTools);
|
||||||
const userRequest = extractUserRequestFromContext(
|
core.exportVariable("DISALLOWED_TOOLS", result.disallowedTools);
|
||||||
preparedContext,
|
|
||||||
githubData,
|
|
||||||
);
|
|
||||||
if (userRequest) {
|
|
||||||
await writeFile(
|
|
||||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/${USER_REQUEST_FILENAME}`,
|
|
||||||
userRequest,
|
|
||||||
);
|
|
||||||
console.log("===== USER REQUEST =====");
|
|
||||||
console.log(userRequest);
|
|
||||||
console.log("========================");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
} catch (error) {
|
||||||
core.setFailed(`Create prompt failed with error: ${error}`);
|
core.setFailed(`Create prompt failed with error: ${error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup SSH signing key after action completes
|
|
||||||
* This is run as a post step for security purposes
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cleanupSshSigning } from "../github/operations/git-config";
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
await cleanupSshSigning();
|
|
||||||
} catch (error) {
|
|
||||||
// Don't fail the action if cleanup fails, just log it
|
|
||||||
console.error("Failed to cleanup SSH signing key:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ export function collectActionInputsPresence(): void {
|
|||||||
max_turns: "",
|
max_turns: "",
|
||||||
use_sticky_comment: "false",
|
use_sticky_comment: "false",
|
||||||
use_commit_signing: "false",
|
use_commit_signing: "false",
|
||||||
ssh_signing_key: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allInputsJson = process.env.ALL_INPUTS;
|
const allInputsJson = process.env.ALL_INPUTS;
|
||||||
|
|||||||
186
src/entrypoints/run.ts
Normal file
186
src/entrypoints/run.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/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();
|
||||||
|
}
|
||||||
@@ -90,13 +90,11 @@ type BaseContext = {
|
|||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
useStickyComment: boolean;
|
useStickyComment: boolean;
|
||||||
useCommitSigning: boolean;
|
useCommitSigning: boolean;
|
||||||
sshSigningKey: string;
|
|
||||||
botId: string;
|
botId: string;
|
||||||
botName: string;
|
botName: string;
|
||||||
allowedBots: string;
|
allowedBots: string;
|
||||||
allowedNonWriteUsers: string;
|
allowedNonWriteUsers: string;
|
||||||
trackProgress: boolean;
|
trackProgress: boolean;
|
||||||
includeFixLinks: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,13 +145,11 @@ export function parseGitHubContext(): GitHubContext {
|
|||||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||||
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
|
|
||||||
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
||||||
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
||||||
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",
|
||||||
includeFixLinks: process.env.INCLUDE_FIX_LINKS === "true",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import { mkdir, writeFile, rm } from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import { homedir } from "os";
|
|
||||||
import type { GitHubContext } from "../context";
|
import type { GitHubContext } from "../context";
|
||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");
|
|
||||||
|
|
||||||
type GitUser = {
|
type GitUser = {
|
||||||
login: string;
|
login: string;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -59,50 +54,3 @@ export async function configureGitAuth(
|
|||||||
|
|
||||||
console.log("Git authentication configured successfully");
|
console.log("Git authentication configured successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure git to use SSH signing for commits
|
|
||||||
* This is an alternative to GitHub API-based commit signing (use_commit_signing)
|
|
||||||
*/
|
|
||||||
export async function setupSshSigning(sshSigningKey: string): Promise<void> {
|
|
||||||
console.log("Configuring SSH signing for commits...");
|
|
||||||
|
|
||||||
// Validate SSH key format
|
|
||||||
if (!sshSigningKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!sshSigningKey.includes("BEGIN") ||
|
|
||||||
!sshSigningKey.includes("PRIVATE KEY")
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid SSH private key format");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create .ssh directory with secure permissions (700)
|
|
||||||
const sshDir = join(homedir(), ".ssh");
|
|
||||||
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
|
||||||
|
|
||||||
// Write the signing key atomically with secure permissions (600)
|
|
||||||
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
|
|
||||||
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
|
|
||||||
|
|
||||||
// Configure git to use SSH signing
|
|
||||||
await $`git config gpg.format ssh`;
|
|
||||||
await $`git config user.signingkey ${SSH_SIGNING_KEY_PATH}`;
|
|
||||||
await $`git config commit.gpgsign true`;
|
|
||||||
|
|
||||||
console.log("✓ Git configured to use SSH signing for commits");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up the SSH signing key file
|
|
||||||
* Should be called in the post step for security
|
|
||||||
*/
|
|
||||||
export async function cleanupSshSigning(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await rm(SSH_SIGNING_KEY_PATH, { force: true });
|
|
||||||
console.log("✓ SSH signing key cleaned up");
|
|
||||||
} catch (error) {
|
|
||||||
console.log("No SSH signing key to clean up");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import type { Mode, ModeOptions, ModeResult } from "../types";
|
|||||||
import type { PreparedContext } from "../../create-prompt/types";
|
import type { PreparedContext } from "../../create-prompt/types";
|
||||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import { parseAllowedTools } from "./parse-tools";
|
import { parseAllowedTools } from "./parse-tools";
|
||||||
import {
|
import { configureGitAuth } from "../../github/operations/git-config";
|
||||||
configureGitAuth,
|
|
||||||
setupSshSigning,
|
|
||||||
} from "../../github/operations/git-config";
|
|
||||||
import type { GitHubContext } from "../../github/context";
|
import type { GitHubContext } from "../../github/context";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
|
|
||||||
@@ -82,27 +79,7 @@ export const agentMode: Mode = {
|
|||||||
|
|
||||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
||||||
// Configure git authentication for agent mode (same as tag mode)
|
// Configure git authentication for agent mode (same as tag mode)
|
||||||
// SSH signing takes precedence if provided
|
if (!context.inputs.useCommitSigning) {
|
||||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
|
||||||
const useApiCommitSigning =
|
|
||||||
context.inputs.useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
if (useSshSigning) {
|
|
||||||
// Setup SSH signing for commits
|
|
||||||
await setupSshSigning(context.inputs.sshSigningKey);
|
|
||||||
|
|
||||||
// Still configure git auth for push operations (user/email and remote URL)
|
|
||||||
const user = {
|
|
||||||
login: context.inputs.botName,
|
|
||||||
id: parseInt(context.inputs.botId),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await configureGitAuth(githubToken, context, user);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to configure git authentication:", error);
|
|
||||||
// Continue anyway - git operations may still work with default config
|
|
||||||
}
|
|
||||||
} else if (!useApiCommitSigning) {
|
|
||||||
// Use bot_id and bot_name from inputs directly
|
// Use bot_id and bot_name from inputs directly
|
||||||
const user = {
|
const user = {
|
||||||
login: context.inputs.botName,
|
login: context.inputs.botName,
|
||||||
@@ -118,16 +95,15 @@ export const agentMode: Mode = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create prompt directory
|
// Generate prompt content - use the user's prompt directly
|
||||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write the prompt file - use the user's prompt directly
|
|
||||||
const promptContent =
|
const promptContent =
|
||||||
context.inputs.prompt ||
|
context.inputs.prompt ||
|
||||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
`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(
|
await writeFile(
|
||||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||||
promptContent,
|
promptContent,
|
||||||
@@ -185,6 +161,8 @@ export const agentMode: Mode = {
|
|||||||
claudeBranch: claudeBranch,
|
claudeBranch: claudeBranch,
|
||||||
},
|
},
|
||||||
mcpConfig: ourMcpConfig,
|
mcpConfig: ourMcpConfig,
|
||||||
|
promptContent,
|
||||||
|
// Agent mode doesn't use the same allowed/disallowed tools mechanism as tag mode
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import { checkContainsTrigger } from "../../github/validation/trigger";
|
|||||||
import { checkHumanActor } from "../../github/validation/actor";
|
import { checkHumanActor } from "../../github/validation/actor";
|
||||||
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
||||||
import { setupBranch } from "../../github/operations/branch";
|
import { setupBranch } from "../../github/operations/branch";
|
||||||
import {
|
import { configureGitAuth } from "../../github/operations/git-config";
|
||||||
configureGitAuth,
|
|
||||||
setupSshSigning,
|
|
||||||
} from "../../github/operations/git-config";
|
|
||||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import {
|
import {
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
extractTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
} from "../../github/data/fetcher";
|
} from "../../github/data/fetcher";
|
||||||
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
|
import {
|
||||||
|
createPrompt,
|
||||||
|
generateDefaultPrompt,
|
||||||
|
generatePromptContent,
|
||||||
|
} from "../../create-prompt";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
import type { PreparedContext } from "../../create-prompt/types";
|
import type { PreparedContext } from "../../create-prompt/types";
|
||||||
import type { FetchDataResult } from "../../github/data/fetcher";
|
import type { FetchDataResult } from "../../github/data/fetcher";
|
||||||
@@ -91,28 +92,8 @@ export const tagMode: Mode = {
|
|||||||
// Setup branch
|
// Setup branch
|
||||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||||
|
|
||||||
// Configure git authentication
|
// Configure git authentication if not using commit signing
|
||||||
// SSH signing takes precedence if provided
|
if (!context.inputs.useCommitSigning) {
|
||||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
|
||||||
const useApiCommitSigning =
|
|
||||||
context.inputs.useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
if (useSshSigning) {
|
|
||||||
// Setup SSH signing for commits
|
|
||||||
await setupSshSigning(context.inputs.sshSigningKey);
|
|
||||||
|
|
||||||
// Still configure git auth for push operations (user/email and remote URL)
|
|
||||||
const user = {
|
|
||||||
login: context.inputs.botName,
|
|
||||||
id: parseInt(context.inputs.botId),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await configureGitAuth(githubToken, context, user);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to configure git authentication:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} else if (!useApiCommitSigning) {
|
|
||||||
// Use bot_id and bot_name from inputs directly
|
// Use bot_id and bot_name from inputs directly
|
||||||
const user = {
|
const user = {
|
||||||
login: context.inputs.botName,
|
login: context.inputs.botName,
|
||||||
@@ -127,13 +108,22 @@ export const tagMode: Mode = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create prompt file
|
// Create prompt
|
||||||
const modeContext = this.prepareContext(context, {
|
const modeContext = this.prepareContext(context, {
|
||||||
commentId,
|
commentId,
|
||||||
baseBranch: branchInfo.baseBranch,
|
baseBranch: branchInfo.baseBranch,
|
||||||
claudeBranch: branchInfo.claudeBranch,
|
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);
|
await createPrompt(tagMode, modeContext, githubData, context);
|
||||||
|
|
||||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||||
@@ -158,9 +148,8 @@ export const tagMode: Mode = {
|
|||||||
...userAllowedMCPTools,
|
...userAllowedMCPTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add git commands when using git CLI (no API commit signing, or SSH signing)
|
// Add git commands when not using commit signing
|
||||||
// SSH signing still uses git CLI, just with signing enabled
|
if (!context.inputs.useCommitSigning) {
|
||||||
if (!useApiCommitSigning) {
|
|
||||||
tagModeTools.push(
|
tagModeTools.push(
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
@@ -171,7 +160,7 @@ export const tagMode: Mode = {
|
|||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// When using API commit signing, use MCP file ops tools
|
// When using commit signing, use MCP file ops tools
|
||||||
tagModeTools.push(
|
tagModeTools.push(
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
"mcp__github_file_ops__delete_files",
|
"mcp__github_file_ops__delete_files",
|
||||||
@@ -212,6 +201,9 @@ export const tagMode: Mode = {
|
|||||||
commentId,
|
commentId,
|
||||||
branchInfo,
|
branchInfo,
|
||||||
mcpConfig: ourMcpConfig,
|
mcpConfig: ourMcpConfig,
|
||||||
|
promptContent: promptResult.promptContent,
|
||||||
|
allowedTools: promptResult.allowedTools,
|
||||||
|
disallowedTools: promptResult.disallowedTools,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -97,4 +97,10 @@ export type ModeResult = {
|
|||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
};
|
};
|
||||||
mcpConfig: 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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export type PrepareResult = {
|
|||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
};
|
};
|
||||||
mcpConfig: 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 = {
|
export type PrepareOptions = {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Extracts the user's request from a trigger comment.
|
|
||||||
*
|
|
||||||
* Given a comment like "@claude /review-pr please check the auth module",
|
|
||||||
* this extracts "/review-pr please check the auth module".
|
|
||||||
*
|
|
||||||
* @param commentBody - The full comment body containing the trigger phrase
|
|
||||||
* @param triggerPhrase - The trigger phrase (e.g., "@claude")
|
|
||||||
* @returns The user's request (text after the trigger phrase), or null if not found
|
|
||||||
*/
|
|
||||||
export function extractUserRequest(
|
|
||||||
commentBody: string | undefined,
|
|
||||||
triggerPhrase: string,
|
|
||||||
): string | null {
|
|
||||||
if (!commentBody) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use string operations instead of regex for better performance and security
|
|
||||||
// (avoids potential ReDoS with large comment bodies)
|
|
||||||
const triggerIndex = commentBody
|
|
||||||
.toLowerCase()
|
|
||||||
.indexOf(triggerPhrase.toLowerCase());
|
|
||||||
if (triggerIndex === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const afterTrigger = commentBody
|
|
||||||
.substring(triggerIndex + triggerPhrase.length)
|
|
||||||
.trim();
|
|
||||||
return afterTrigger || null;
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import { extractUserRequest } from "../src/utils/extract-user-request";
|
|
||||||
|
|
||||||
describe("extractUserRequest", () => {
|
|
||||||
test("extracts text after @claude trigger", () => {
|
|
||||||
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts slash command with arguments", () => {
|
|
||||||
expect(
|
|
||||||
extractUserRequest(
|
|
||||||
"@claude /review-pr please check the auth module",
|
|
||||||
"@claude",
|
|
||||||
),
|
|
||||||
).toBe("/review-pr please check the auth module");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase with extra whitespace", () => {
|
|
||||||
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase at start of multiline comment", () => {
|
|
||||||
const comment = `@claude /review-pr
|
|
||||||
Please review this PR carefully.
|
|
||||||
Focus on security issues.`;
|
|
||||||
expect(extractUserRequest(comment, "@claude")).toBe(
|
|
||||||
`/review-pr
|
|
||||||
Please review this PR carefully.
|
|
||||||
Focus on security issues.`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase in middle of text", () => {
|
|
||||||
expect(
|
|
||||||
extractUserRequest("Hey team, @claude can you review this?", "@claude"),
|
|
||||||
).toBe("can you review this?");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for empty comment body", () => {
|
|
||||||
expect(extractUserRequest("", "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for undefined comment body", () => {
|
|
||||||
expect(extractUserRequest(undefined, "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null when trigger phrase not found", () => {
|
|
||||||
expect(extractUserRequest("Please review this PR", "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null when only trigger phrase with no request", () => {
|
|
||||||
expect(extractUserRequest("@claude", "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles custom trigger phrase", () => {
|
|
||||||
expect(extractUserRequest("/claude help me", "/claude")).toBe("help me");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase with special regex characters", () => {
|
|
||||||
expect(
|
|
||||||
extractUserRequest("@claude[bot] do something", "@claude[bot]"),
|
|
||||||
).toBe("do something");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is case insensitive", () => {
|
|
||||||
expect(extractUserRequest("@CLAUDE /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
expect(extractUserRequest("@Claude /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,13 +32,11 @@ describe("prepareMcpConfig", () => {
|
|||||||
branchPrefix: "",
|
branchPrefix: "",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ const defaultInputs = {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ describe("Agent Mode", () => {
|
|||||||
claudeBranch: undefined,
|
claudeBranch: undefined,
|
||||||
},
|
},
|
||||||
mcpConfig: expect.any(String),
|
mcpConfig: expect.any(String),
|
||||||
|
promptContent: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ describe("detectMode with enhanced routing", () => {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: "123456",
|
botId: "123456",
|
||||||
botName: "claude-bot",
|
botName: "claude-bot",
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,11 @@ describe("checkWritePermissions", () => {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import {
|
|
||||||
describe,
|
|
||||||
test,
|
|
||||||
expect,
|
|
||||||
afterEach,
|
|
||||||
beforeAll,
|
|
||||||
afterAll,
|
|
||||||
} from "bun:test";
|
|
||||||
import { mkdir, writeFile, rm, readFile, stat } from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
|
|
||||||
describe("SSH Signing", () => {
|
|
||||||
// Use a temp directory for tests
|
|
||||||
const testTmpDir = join(tmpdir(), "claude-ssh-signing-test");
|
|
||||||
const testSshDir = join(testTmpDir, ".ssh");
|
|
||||||
const testKeyPath = join(testSshDir, "claude_signing_key");
|
|
||||||
const testKey =
|
|
||||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await mkdir(testTmpDir, { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await rm(testTmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
// Clean up test key if it exists
|
|
||||||
try {
|
|
||||||
await rm(testKeyPath, { force: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setupSshSigning file operations", () => {
|
|
||||||
test("should write key file atomically with correct permissions", async () => {
|
|
||||||
// Create the directory with secure permissions (same as setupSshSigning does)
|
|
||||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
|
||||||
|
|
||||||
// Write key atomically with proper permissions (same as setupSshSigning does)
|
|
||||||
await writeFile(testKeyPath, testKey, { mode: 0o600 });
|
|
||||||
|
|
||||||
// Verify key was written
|
|
||||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
|
||||||
expect(keyContent).toBe(testKey);
|
|
||||||
|
|
||||||
// Verify permissions (0o600 = 384 in decimal for permission bits only)
|
|
||||||
const stats = await stat(testKeyPath);
|
|
||||||
const permissions = stats.mode & 0o777; // Get only permission bits
|
|
||||||
expect(permissions).toBe(0o600);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should create .ssh directory with secure permissions", async () => {
|
|
||||||
// Clean up first
|
|
||||||
await rm(testSshDir, { recursive: true, force: true });
|
|
||||||
|
|
||||||
// Create directory with secure permissions (same as setupSshSigning does)
|
|
||||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
|
||||||
|
|
||||||
// Verify directory exists
|
|
||||||
const dirStats = await stat(testSshDir);
|
|
||||||
expect(dirStats.isDirectory()).toBe(true);
|
|
||||||
|
|
||||||
// Verify directory permissions
|
|
||||||
const dirPermissions = dirStats.mode & 0o777;
|
|
||||||
expect(dirPermissions).toBe(0o700);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setupSshSigning validation", () => {
|
|
||||||
test("should reject empty SSH key", () => {
|
|
||||||
const emptyKey = "";
|
|
||||||
expect(() => {
|
|
||||||
if (!emptyKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
}).toThrow("SSH signing key cannot be empty");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject whitespace-only SSH key", () => {
|
|
||||||
const whitespaceKey = " \n\t ";
|
|
||||||
expect(() => {
|
|
||||||
if (!whitespaceKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
}).toThrow("SSH signing key cannot be empty");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject invalid SSH key format", () => {
|
|
||||||
const invalidKey = "not a valid key";
|
|
||||||
expect(() => {
|
|
||||||
if (
|
|
||||||
!invalidKey.includes("BEGIN") ||
|
|
||||||
!invalidKey.includes("PRIVATE KEY")
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid SSH private key format");
|
|
||||||
}
|
|
||||||
}).toThrow("Invalid SSH private key format");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept valid SSH key format", () => {
|
|
||||||
const validKey =
|
|
||||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nkey-content\n-----END OPENSSH PRIVATE KEY-----";
|
|
||||||
expect(() => {
|
|
||||||
if (!validKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
if (!validKey.includes("BEGIN") || !validKey.includes("PRIVATE KEY")) {
|
|
||||||
throw new Error("Invalid SSH private key format");
|
|
||||||
}
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cleanupSshSigning file operations", () => {
|
|
||||||
test("should remove the signing key file", async () => {
|
|
||||||
// Create the key file first
|
|
||||||
await mkdir(testSshDir, { recursive: true });
|
|
||||||
await writeFile(testKeyPath, testKey, { mode: 0o600 });
|
|
||||||
|
|
||||||
// Verify it exists
|
|
||||||
const existsBefore = await stat(testKeyPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
expect(existsBefore).toBe(true);
|
|
||||||
|
|
||||||
// Clean up (same operation as cleanupSshSigning)
|
|
||||||
await rm(testKeyPath, { force: true });
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
const existsAfter = await stat(testKeyPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
expect(existsAfter).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not throw if key file does not exist", async () => {
|
|
||||||
// Make sure file doesn't exist
|
|
||||||
await rm(testKeyPath, { force: true });
|
|
||||||
|
|
||||||
// Should not throw (rm with force: true doesn't throw on missing files)
|
|
||||||
await expect(rm(testKeyPath, { force: true })).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SSH Signing Mode Detection", () => {
|
|
||||||
test("sshSigningKey should take precedence over useCommitSigning", () => {
|
|
||||||
// When both are set, SSH signing takes precedence
|
|
||||||
const sshSigningKey = "test-key";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useSshSigning).toBe(true);
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("useCommitSigning should work when sshSigningKey is not set", () => {
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useSshSigning).toBe(false);
|
|
||||||
expect(useApiCommitSigning).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("neither signing method when both are false/empty", () => {
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = false;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useSshSigning).toBe(false);
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("git CLI tools should be used when sshSigningKey is set", () => {
|
|
||||||
// This tests the logic in tag mode for tool selection
|
|
||||||
const sshSigningKey = "test-key";
|
|
||||||
const useCommitSigning = true; // Even if this is true
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
// When SSH signing is used, we should use git CLI (not API)
|
|
||||||
const shouldUseGitCli = !useApiCommitSigning;
|
|
||||||
expect(shouldUseGitCli).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP file ops should only be used with API commit signing", () => {
|
|
||||||
// Case 1: API commit signing
|
|
||||||
{
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useApiCommitSigning).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: SSH signing (should NOT use API)
|
|
||||||
{
|
|
||||||
const sshSigningKey = "test-key";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: No signing (should NOT use API)
|
|
||||||
{
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = false;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Context parsing", () => {
|
|
||||||
test("sshSigningKey should be parsed from environment", () => {
|
|
||||||
// Test that context.ts parses SSH_SIGNING_KEY correctly
|
|
||||||
const testCases = [
|
|
||||||
{ env: "test-key", expected: "test-key" },
|
|
||||||
{ env: "", expected: "" },
|
|
||||||
{ env: undefined, expected: "" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { env, expected } of testCases) {
|
|
||||||
const result = env || "";
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user