mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
2 Commits
v1.0.27
...
add-plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23e406ca24 | ||
|
|
f5f27d4716 |
2
.github/workflows/claude-review.yml
vendored
2
.github/workflows/claude-review.yml
vendored
@@ -2,7 +2,7 @@ name: PR Review
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened]
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
|
|||||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -36,4 +36,4 @@ jobs:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--allowedTools "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
--allowedTools "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||||
--model "claude-opus-4-5"
|
--model "claude-opus-4-1-20250805"
|
||||||
|
|||||||
4
.github/workflows/sync-base-action.yml
vendored
4
.github/workflows/sync-base-action.yml
vendored
@@ -94,5 +94,5 @@ jobs:
|
|||||||
echo "✅ Successfully synced \`base-action\` directory to [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action)" >> $GITHUB_STEP_SUMMARY
|
echo "✅ Successfully synced \`base-action\` directory to [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- **Source commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/anthropics/claude-code-action/commit/${GITHUB_SHA})" >> $GITHUB_STEP_SUMMARY
|
echo "- **Source commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/anthropics/claude-code-action/commit/${GITHUB_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- **Triggered by**: $GITHUB_EVENT_NAME" >> $GITHUB_STEP_SUMMARY
|
echo "- **Triggered by**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- **Actor**: @$GITHUB_ACTOR" >> $GITHUB_STEP_SUMMARY
|
echo "- **Actor**: @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
58
.github/workflows/test-base-action.yml
vendored
58
.github/workflows/test-base-action.yml
vendored
@@ -118,61 +118,3 @@ jobs:
|
|||||||
echo "❌ Execution log file not found"
|
echo "❌ Execution log file not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test-agent-sdk:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
|
|
||||||
- name: Test with Agent SDK
|
|
||||||
id: sdk-test
|
|
||||||
uses: ./base-action
|
|
||||||
env:
|
|
||||||
USE_AGENT_SDK: "true"
|
|
||||||
with:
|
|
||||||
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
allowed_tools: "LS,Read"
|
|
||||||
|
|
||||||
- name: Verify SDK output
|
|
||||||
run: |
|
|
||||||
OUTPUT_FILE="${{ steps.sdk-test.outputs.execution_file }}"
|
|
||||||
CONCLUSION="${{ steps.sdk-test.outputs.conclusion }}"
|
|
||||||
|
|
||||||
echo "Conclusion: $CONCLUSION"
|
|
||||||
echo "Output file: $OUTPUT_FILE"
|
|
||||||
|
|
||||||
if [ "$CONCLUSION" = "success" ]; then
|
|
||||||
echo "✅ Action completed successfully with Agent SDK"
|
|
||||||
else
|
|
||||||
echo "❌ Action failed with Agent SDK"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$OUTPUT_FILE" ]; then
|
|
||||||
if [ -s "$OUTPUT_FILE" ]; then
|
|
||||||
echo "✅ Execution log file created successfully with content"
|
|
||||||
echo "Validating JSON format:"
|
|
||||||
if jq . "$OUTPUT_FILE" > /dev/null 2>&1; then
|
|
||||||
echo "✅ Output is valid JSON"
|
|
||||||
# Verify SDK output contains total_cost_usd (SDK field name)
|
|
||||||
if jq -e '.[] | select(.type == "result") | .total_cost_usd' "$OUTPUT_FILE" > /dev/null 2>&1; then
|
|
||||||
echo "✅ SDK output contains total_cost_usd field"
|
|
||||||
else
|
|
||||||
echo "❌ SDK output missing total_cost_usd field"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Content preview:"
|
|
||||||
head -c 500 "$OUTPUT_FILE"
|
|
||||||
else
|
|
||||||
echo "❌ Output is not valid JSON"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "❌ Execution log file is empty"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "❌ Execution log file not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|||||||
307
.github/workflows/test-structured-output.yml
vendored
307
.github/workflows/test-structured-output.yml
vendored
@@ -1,307 +0,0 @@
|
|||||||
name: Test Structured Outputs
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-basic-types:
|
|
||||||
name: Test Basic Type Conversions
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
|
|
||||||
- name: Test with explicit values
|
|
||||||
id: test
|
|
||||||
uses: ./base-action
|
|
||||||
with:
|
|
||||||
prompt: |
|
|
||||||
Run this command: echo "test"
|
|
||||||
|
|
||||||
Then return EXACTLY these values:
|
|
||||||
- text_field: "hello"
|
|
||||||
- number_field: 42
|
|
||||||
- boolean_true: true
|
|
||||||
- boolean_false: false
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
claude_args: |
|
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"text_field":{"type":"string"},"number_field":{"type":"number"},"boolean_true":{"type":"boolean"},"boolean_false":{"type":"boolean"}},"required":["text_field","number_field","boolean_true","boolean_false"]}'
|
|
||||||
|
|
||||||
- name: Verify outputs
|
|
||||||
run: |
|
|
||||||
# Parse the structured_output JSON
|
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
|
||||||
|
|
||||||
# Test string pass-through
|
|
||||||
TEXT_FIELD=$(echo "$OUTPUT" | jq -r '.text_field')
|
|
||||||
if [ "$TEXT_FIELD" != "hello" ]; then
|
|
||||||
echo "❌ String: expected 'hello', got '$TEXT_FIELD'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test number → string conversion
|
|
||||||
NUMBER_FIELD=$(echo "$OUTPUT" | jq -r '.number_field')
|
|
||||||
if [ "$NUMBER_FIELD" != "42" ]; then
|
|
||||||
echo "❌ Number: expected '42', got '$NUMBER_FIELD'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test boolean → "true" conversion
|
|
||||||
BOOLEAN_TRUE=$(echo "$OUTPUT" | jq -r '.boolean_true')
|
|
||||||
if [ "$BOOLEAN_TRUE" != "true" ]; then
|
|
||||||
echo "❌ Boolean true: expected 'true', got '$BOOLEAN_TRUE'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test boolean → "false" conversion
|
|
||||||
BOOLEAN_FALSE=$(echo "$OUTPUT" | jq -r '.boolean_false')
|
|
||||||
if [ "$BOOLEAN_FALSE" != "false" ]; then
|
|
||||||
echo "❌ Boolean false: expected 'false', got '$BOOLEAN_FALSE'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All basic type conversions correct"
|
|
||||||
|
|
||||||
test-complex-types:
|
|
||||||
name: Test Arrays and Objects
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
|
|
||||||
- name: Test complex types
|
|
||||||
id: test
|
|
||||||
uses: ./base-action
|
|
||||||
with:
|
|
||||||
prompt: |
|
|
||||||
Run: echo "ready"
|
|
||||||
|
|
||||||
Return EXACTLY:
|
|
||||||
- items: ["apple", "banana", "cherry"]
|
|
||||||
- config: {"key": "value", "count": 3}
|
|
||||||
- empty_array: []
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
claude_args: |
|
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}},"config":{"type":"object"},"empty_array":{"type":"array"}},"required":["items","config","empty_array"]}'
|
|
||||||
|
|
||||||
- name: Verify JSON stringification
|
|
||||||
run: |
|
|
||||||
# Parse the structured_output JSON
|
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
|
||||||
|
|
||||||
# Arrays should be JSON stringified
|
|
||||||
if ! echo "$OUTPUT" | jq -e '.items | length == 3' > /dev/null; then
|
|
||||||
echo "❌ Array not properly formatted"
|
|
||||||
echo "$OUTPUT" | jq '.items'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Objects should be JSON stringified
|
|
||||||
if ! echo "$OUTPUT" | jq -e '.config.key == "value"' > /dev/null; then
|
|
||||||
echo "❌ Object not properly formatted"
|
|
||||||
echo "$OUTPUT" | jq '.config'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Empty arrays should work
|
|
||||||
if ! echo "$OUTPUT" | jq -e '.empty_array | length == 0' > /dev/null; then
|
|
||||||
echo "❌ Empty array not properly formatted"
|
|
||||||
echo "$OUTPUT" | jq '.empty_array'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All complex types handled correctly"
|
|
||||||
|
|
||||||
test-edge-cases:
|
|
||||||
name: Test Edge Cases
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
|
|
||||||
- name: Test edge cases
|
|
||||||
id: test
|
|
||||||
uses: ./base-action
|
|
||||||
with:
|
|
||||||
prompt: |
|
|
||||||
Run: echo "test"
|
|
||||||
|
|
||||||
Return EXACTLY:
|
|
||||||
- zero: 0
|
|
||||||
- empty_string: ""
|
|
||||||
- negative: -5
|
|
||||||
- decimal: 3.14
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
claude_args: |
|
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"zero":{"type":"number"},"empty_string":{"type":"string"},"negative":{"type":"number"},"decimal":{"type":"number"}},"required":["zero","empty_string","negative","decimal"]}'
|
|
||||||
|
|
||||||
- name: Verify edge cases
|
|
||||||
run: |
|
|
||||||
# Parse the structured_output JSON
|
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
|
||||||
|
|
||||||
# Zero should be "0", not empty or falsy
|
|
||||||
ZERO=$(echo "$OUTPUT" | jq -r '.zero')
|
|
||||||
if [ "$ZERO" != "0" ]; then
|
|
||||||
echo "❌ Zero: expected '0', got '$ZERO'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Empty string should be empty (not "null" or missing)
|
|
||||||
EMPTY_STRING=$(echo "$OUTPUT" | jq -r '.empty_string')
|
|
||||||
if [ "$EMPTY_STRING" != "" ]; then
|
|
||||||
echo "❌ Empty string: expected '', got '$EMPTY_STRING'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Negative numbers should work
|
|
||||||
NEGATIVE=$(echo "$OUTPUT" | jq -r '.negative')
|
|
||||||
if [ "$NEGATIVE" != "-5" ]; then
|
|
||||||
echo "❌ Negative: expected '-5', got '$NEGATIVE'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Decimals should preserve precision
|
|
||||||
DECIMAL=$(echo "$OUTPUT" | jq -r '.decimal')
|
|
||||||
if [ "$DECIMAL" != "3.14" ]; then
|
|
||||||
echo "❌ Decimal: expected '3.14', got '$DECIMAL'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All edge cases handled correctly"
|
|
||||||
|
|
||||||
test-name-sanitization:
|
|
||||||
name: Test Output Name Sanitization
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
|
|
||||||
- name: Test special characters in field names
|
|
||||||
id: test
|
|
||||||
uses: ./base-action
|
|
||||||
with:
|
|
||||||
prompt: |
|
|
||||||
Run: echo "test"
|
|
||||||
Return EXACTLY: {test-result: "passed", item_count: 10}
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
claude_args: |
|
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"test-result":{"type":"string"},"item_count":{"type":"number"}},"required":["test-result","item_count"]}'
|
|
||||||
|
|
||||||
- name: Verify sanitized names work
|
|
||||||
run: |
|
|
||||||
# Parse the structured_output JSON
|
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
|
||||||
|
|
||||||
# Hyphens should be preserved in the JSON
|
|
||||||
TEST_RESULT=$(echo "$OUTPUT" | jq -r '.["test-result"]')
|
|
||||||
if [ "$TEST_RESULT" != "passed" ]; then
|
|
||||||
echo "❌ Hyphenated name failed: expected 'passed', got '$TEST_RESULT'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Underscores should work
|
|
||||||
ITEM_COUNT=$(echo "$OUTPUT" | jq -r '.item_count')
|
|
||||||
if [ "$ITEM_COUNT" != "10" ]; then
|
|
||||||
echo "❌ Underscore name failed: expected '10', got '$ITEM_COUNT'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Name sanitization works"
|
|
||||||
|
|
||||||
test-execution-file-structure:
|
|
||||||
name: Test Execution File Format
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
|
|
||||||
- name: Run with structured output
|
|
||||||
id: test
|
|
||||||
uses: ./base-action
|
|
||||||
with:
|
|
||||||
prompt: "Run: echo 'complete'. Return: {done: true}"
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
claude_args: |
|
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}'
|
|
||||||
|
|
||||||
- name: Verify execution file contains structured_output
|
|
||||||
run: |
|
|
||||||
FILE="${{ steps.test.outputs.execution_file }}"
|
|
||||||
|
|
||||||
# Check file exists
|
|
||||||
if [ ! -f "$FILE" ]; then
|
|
||||||
echo "❌ Execution file missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for structured_output field
|
|
||||||
if ! jq -e '.[] | select(.type == "result") | .structured_output' "$FILE" > /dev/null; then
|
|
||||||
echo "❌ No structured_output in execution file"
|
|
||||||
cat "$FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify the actual value
|
|
||||||
DONE=$(jq -r '.[] | select(.type == "result") | .structured_output.done' "$FILE")
|
|
||||||
if [ "$DONE" != "true" ]; then
|
|
||||||
echo "❌ Wrong value in execution file"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Execution file format correct"
|
|
||||||
|
|
||||||
test-summary:
|
|
||||||
name: Summary
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- test-basic-types
|
|
||||||
- test-complex-types
|
|
||||||
- test-edge-cases
|
|
||||||
- test-name-sanitization
|
|
||||||
- test-execution-file-structure
|
|
||||||
if: always()
|
|
||||||
steps:
|
|
||||||
- name: Generate Summary
|
|
||||||
run: |
|
|
||||||
echo "# Structured Output Tests (Optimized)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Fast, deterministic tests using explicit prompts" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Test | Result |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Basic Types | ${{ needs.test-basic-types.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Complex Types | ${{ needs.test-complex-types.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Edge Cases | ${{ needs.test-edge-cases.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Name Sanitization | ${{ needs.test-name-sanitization.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Execution File | ${{ needs.test-execution-file-structure.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
# Check if all passed
|
|
||||||
ALL_PASSED=${{
|
|
||||||
needs.test-basic-types.result == 'success' &&
|
|
||||||
needs.test-complex-types.result == 'success' &&
|
|
||||||
needs.test-edge-cases.result == 'success' &&
|
|
||||||
needs.test-name-sanitization.result == 'success' &&
|
|
||||||
needs.test-execution-file-structure.result == 'success'
|
|
||||||
}}
|
|
||||||
|
|
||||||
if [ "$ALL_PASSED" = "true" ]; then
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "## ✅ All Tests Passed" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "## ❌ Some Tests Failed" >> $GITHUB_STEP_SUMMARY
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code Action
|
# Claude Code Action
|
||||||
|
|
||||||
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, Google Vertex AI, and Microsoft Foundry.
|
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
|
|||||||
- 💬 **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews
|
- 💬 **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews
|
||||||
- 🛠️ **Flexible Tool Access**: Access to GitHub APIs and file operations (additional tools can be enabled via configuration)
|
- 🛠️ **Flexible Tool Access**: Access to GitHub APIs and file operations (additional tools can be enabled via configuration)
|
||||||
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
||||||
- 📊 **Structured Outputs**: Get validated JSON results that automatically become GitHub Action outputs for complex automations
|
|
||||||
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
||||||
- ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK
|
- ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ This command will guide you through setting up the GitHub app and required secre
|
|||||||
**Note**:
|
**Note**:
|
||||||
|
|
||||||
- You must be a repository admin to install the GitHub app and add secrets
|
- You must be a repository admin to install the GitHub app and add secrets
|
||||||
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock, Google Vertex AI, or Microsoft Foundry setup, see [docs/cloud-providers.md](./docs/cloud-providers.md).
|
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock or Google Vertex AI setup, see [docs/cloud-providers.md](./docs/cloud-providers.md).
|
||||||
|
|
||||||
## 📚 Solutions & Use Cases
|
## 📚 Solutions & Use Cases
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ Each solution includes complete working examples, configuration details, and exp
|
|||||||
- [Custom Automations](./docs/custom-automations.md) - Examples of automated workflows and custom prompts
|
- [Custom Automations](./docs/custom-automations.md) - Examples of automated workflows and custom prompts
|
||||||
- [Configuration](./docs/configuration.md) - MCP servers, permissions, environment variables, and advanced settings
|
- [Configuration](./docs/configuration.md) - MCP servers, permissions, environment variables, and advanced settings
|
||||||
- [Experimental Features](./docs/experimental.md) - Execution modes and network restrictions
|
- [Experimental Features](./docs/experimental.md) - Execution modes and network restrictions
|
||||||
- [Cloud Providers](./docs/cloud-providers.md) - AWS Bedrock, Google Vertex AI, and Microsoft Foundry setup
|
- [Cloud Providers](./docs/cloud-providers.md) - AWS Bedrock and Google Vertex AI setup
|
||||||
- [Capabilities & Limitations](./docs/capabilities-and-limitations.md) - What Claude can and cannot do
|
- [Capabilities & Limitations](./docs/capabilities-and-limitations.md) - What Claude can and cannot do
|
||||||
- [Security](./docs/security.md) - Access control, permissions, and commit signing
|
- [Security](./docs/security.md) - Access control, permissions, and commit signing
|
||||||
- [FAQ](./docs/faq.md) - Common questions and troubleshooting
|
- [FAQ](./docs/faq.md) - Common questions and troubleshooting
|
||||||
|
|||||||
89
action.yml
89
action.yml
@@ -41,10 +41,14 @@ inputs:
|
|||||||
description: "Claude Code settings as JSON string or path to settings JSON file"
|
description: "Claude Code settings as JSON string or path to settings JSON file"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
plugins:
|
||||||
|
description: "Comma-separated list of Claude Code plugins to install (e.g., 'plugin-name1,plugin-name2')"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
# Auth configuration
|
# Auth configuration
|
||||||
anthropic_api_key:
|
anthropic_api_key:
|
||||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex/Foundry)"
|
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
||||||
required: false
|
required: false
|
||||||
claude_code_oauth_token:
|
claude_code_oauth_token:
|
||||||
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||||
@@ -60,10 +64,6 @@ inputs:
|
|||||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
use_foundry:
|
|
||||||
description: "Use Microsoft Foundry with OIDC authentication instead of direct Anthropic API"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
claude_args:
|
claude_args:
|
||||||
description: "Additional arguments to pass directly to Claude CLI"
|
description: "Additional arguments to pass directly to Claude CLI"
|
||||||
@@ -93,6 +93,10 @@ 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"
|
||||||
|
experimental_allowed_domains:
|
||||||
|
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
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
|
||||||
@@ -101,18 +105,6 @@ inputs:
|
|||||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
show_full_output:
|
|
||||||
description: "Show full JSON output from Claude Code. WARNING: This outputs ALL Claude messages including tool execution results which may contain secrets, API keys, or other sensitive information. These logs are publicly visible in GitHub Actions. Only enable for debugging in non-sensitive environments."
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
plugins:
|
|
||||||
description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
plugin_marketplaces:
|
|
||||||
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -124,12 +116,6 @@ outputs:
|
|||||||
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.prepare.outputs.github_token }}
|
||||||
structured_output:
|
|
||||||
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
|
|
||||||
value: ${{ steps.claude-code.outputs.structured_output }}
|
|
||||||
session_id:
|
|
||||||
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
|
|
||||||
value: ${{ steps.claude-code.outputs.session_id }}
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
@@ -143,12 +129,10 @@ runs:
|
|||||||
- name: Setup Custom Bun Path
|
- name: Setup Custom Bun Path
|
||||||
if: inputs.path_to_bun_executable != ''
|
if: inputs.path_to_bun_executable != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
|
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
|
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
|
||||||
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -187,8 +171,6 @@ runs:
|
|||||||
- name: Install Base Action Dependencies
|
- name: Install Base Action Dependencies
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "Installing base-action dependencies..."
|
echo "Installing base-action dependencies..."
|
||||||
cd ${GITHUB_ACTION_PATH}/base-action
|
cd ${GITHUB_ACTION_PATH}/base-action
|
||||||
@@ -197,32 +179,26 @@ runs:
|
|||||||
cd -
|
cd -
|
||||||
|
|
||||||
# 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 "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.0.74"
|
echo "Installing Claude Code..."
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.24
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Installation attempt $attempt..."
|
|
||||||
if command -v timeout &> /dev/null; then
|
|
||||||
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
|
||||||
else
|
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
|
||||||
fi
|
|
||||||
if [ $attempt -eq 3 ]; then
|
|
||||||
echo "Failed to install Claude Code after 3 attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Installation failed, retrying..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
echo "Claude Code installed successfully"
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
else
|
else
|
||||||
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
|
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
|
||||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Setup Network Restrictions
|
||||||
|
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||||
|
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||||
|
env:
|
||||||
|
EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }}
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
@@ -236,18 +212,15 @@ runs:
|
|||||||
CLAUDE_CODE_ACTION: "1"
|
CLAUDE_CODE_ACTION: "1"
|
||||||
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||||
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
|
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
|
||||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||||
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
||||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
|
||||||
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
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"
|
||||||
|
|
||||||
@@ -258,14 +231,12 @@ runs:
|
|||||||
ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }}
|
ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }}
|
||||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_FOUNDRY: ${{ inputs.use_foundry == 'true' && '1' || '' }}
|
|
||||||
|
|
||||||
# AWS configuration
|
# AWS configuration
|
||||||
AWS_REGION: ${{ env.AWS_REGION }}
|
AWS_REGION: ${{ env.AWS_REGION }}
|
||||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||||
AWS_BEARER_TOKEN_BEDROCK: ${{ env.AWS_BEARER_TOKEN_BEDROCK }}
|
|
||||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||||
|
|
||||||
# GCP configuration
|
# GCP configuration
|
||||||
@@ -279,13 +250,6 @@ runs:
|
|||||||
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }}
|
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }}
|
||||||
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
||||||
|
|
||||||
# Microsoft Foundry configuration
|
|
||||||
ANTHROPIC_FOUNDRY_RESOURCE: ${{ env.ANTHROPIC_FOUNDRY_RESOURCE }}
|
|
||||||
ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL }}
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ env.ANTHROPIC_DEFAULT_SONNET_MODEL }}
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_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.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -297,7 +261,6 @@ runs:
|
|||||||
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }}
|
CLAUDE_COMMENT_ID: ${{ steps.prepare.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.prepare.outputs.GITHUB_TOKEN }}
|
||||||
GH_TOKEN: ${{ steps.prepare.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.prepare.outputs.CLAUDE_BRANCH }}
|
||||||
|
|||||||
@@ -85,32 +85,29 @@ Add the following to your workflow file:
|
|||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
| Input | Description | Required | Default |
|
| Input | Description | Required | Default |
|
||||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
|
| ------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
|
||||||
| `prompt` | The prompt to send to Claude Code | No\* | '' |
|
| `prompt` | The prompt to send to Claude Code | No\* | '' |
|
||||||
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
|
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
|
||||||
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
|
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
|
||||||
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
|
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
|
||||||
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
|
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
|
||||||
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
|
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
|
||||||
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
|
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
|
||||||
| `system_prompt` | Override system prompt | No | '' |
|
| `system_prompt` | Override system prompt | No | '' |
|
||||||
| `append_system_prompt` | Append to system prompt | No | '' |
|
| `append_system_prompt` | Append to system prompt | No | '' |
|
||||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
|
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
|
||||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
|
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
|
||||||
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
|
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
|
||||||
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
|
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
|
||||||
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
|
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
|
||||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
|
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
|
||||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||||
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
|
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
|
||||||
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | 'false'\*\* |
|
|
||||||
|
|
||||||
\*Either `prompt` or `prompt_file` must be provided, but not both.
|
\*Either `prompt` or `prompt_file` must be provided, but not both.
|
||||||
|
|
||||||
\*\*`show_full_output` is automatically enabled when GitHub Actions debug mode is active. See [security documentation](../docs/security.md#️-full-output-security-warning) for important security considerations.
|
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
| Output | Description |
|
| Output | Description |
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ inputs:
|
|||||||
description: "Claude Code settings as JSON string or path to settings JSON file"
|
description: "Claude Code settings as JSON string or path to settings JSON file"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
plugins:
|
||||||
|
description: "Comma-separated list of Claude Code plugins to install (e.g., 'plugin-name1,plugin-name2')"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
# Action settings
|
# Action settings
|
||||||
claude_args:
|
claude_args:
|
||||||
@@ -42,10 +46,6 @@ inputs:
|
|||||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
use_foundry:
|
|
||||||
description: "Use Microsoft Foundry with OIDC authentication instead of direct Anthropic API"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
use_node_cache:
|
use_node_cache:
|
||||||
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
||||||
@@ -59,18 +59,6 @@ inputs:
|
|||||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
show_full_output:
|
|
||||||
description: "Show full JSON output from Claude Code. WARNING: This outputs ALL Claude messages including tool execution results which may contain secrets, API keys, or other sensitive information. These logs are publicly visible in GitHub Actions. Only enable for debugging in non-sensitive environments."
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
plugins:
|
|
||||||
description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
plugin_marketplaces:
|
|
||||||
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
conclusion:
|
conclusion:
|
||||||
@@ -79,12 +67,6 @@ outputs:
|
|||||||
execution_file:
|
execution_file:
|
||||||
description: "Path to the JSON file containing Claude Code execution log"
|
description: "Path to the JSON file containing Claude Code execution log"
|
||||||
value: ${{ steps.run_claude.outputs.execution_file }}
|
value: ${{ steps.run_claude.outputs.execution_file }}
|
||||||
structured_output:
|
|
||||||
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
|
|
||||||
value: ${{ steps.run_claude.outputs.structured_output }}
|
|
||||||
session_id:
|
|
||||||
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
|
|
||||||
value: ${{ steps.run_claude.outputs.session_id }}
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
@@ -104,12 +86,10 @@ runs:
|
|||||||
- name: Setup Custom Bun Path
|
- name: Setup Custom Bun Path
|
||||||
if: inputs.path_to_bun_executable != ''
|
if: inputs.path_to_bun_executable != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
|
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
|
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
|
||||||
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -120,31 +100,14 @@ runs:
|
|||||||
|
|
||||||
- name: Install Claude Code
|
- name: Install Claude Code
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.0.74"
|
echo "Installing Claude Code..."
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.24
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Installation attempt $attempt..."
|
|
||||||
if command -v timeout &> /dev/null; then
|
|
||||||
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
|
||||||
else
|
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
|
||||||
fi
|
|
||||||
if [ $attempt -eq 3 ]; then
|
|
||||||
echo "Failed to install Claude Code after 3 attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Installation failed, retrying..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
echo "Claude Code installed successfully"
|
|
||||||
else
|
else
|
||||||
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
|
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
|
||||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -164,12 +127,10 @@ runs:
|
|||||||
INPUT_PROMPT: ${{ inputs.prompt }}
|
INPUT_PROMPT: ${{ inputs.prompt }}
|
||||||
INPUT_PROMPT_FILE: ${{ inputs.prompt_file }}
|
INPUT_PROMPT_FILE: ${{ inputs.prompt_file }}
|
||||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||||
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
|
||||||
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||||
@@ -179,14 +140,12 @@ runs:
|
|||||||
# Only set provider flags if explicitly true, since any value (including "false") is truthy
|
# Only set provider flags if explicitly true, since any value (including "false") is truthy
|
||||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_FOUNDRY: ${{ inputs.use_foundry == 'true' && '1' || '' }}
|
|
||||||
|
|
||||||
# AWS configuration
|
# AWS configuration
|
||||||
AWS_REGION: ${{ env.AWS_REGION }}
|
AWS_REGION: ${{ env.AWS_REGION }}
|
||||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||||
AWS_BEARER_TOKEN_BEDROCK: ${{ env.AWS_BEARER_TOKEN_BEDROCK }}
|
|
||||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||||
|
|
||||||
# GCP configuration
|
# GCP configuration
|
||||||
@@ -194,10 +153,3 @@ runs:
|
|||||||
CLOUD_ML_REGION: ${{ env.CLOUD_ML_REGION }}
|
CLOUD_ML_REGION: ${{ env.CLOUD_ML_REGION }}
|
||||||
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
|
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
|
||||||
ANTHROPIC_VERTEX_BASE_URL: ${{ env.ANTHROPIC_VERTEX_BASE_URL }}
|
ANTHROPIC_VERTEX_BASE_URL: ${{ env.ANTHROPIC_VERTEX_BASE_URL }}
|
||||||
|
|
||||||
# Microsoft Foundry configuration
|
|
||||||
ANTHROPIC_FOUNDRY_RESOURCE: ${{ env.ANTHROPIC_FOUNDRY_RESOURCE }}
|
|
||||||
ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL }}
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ env.ANTHROPIC_DEFAULT_SONNET_MODEL }}
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_MODEL }}
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"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.74",
|
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -27,40 +25,8 @@
|
|||||||
|
|
||||||
"@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.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=="],
|
||||||
|
|
||||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
|
||||||
|
|
||||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
|
||||||
|
|
||||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
|
||||||
|
|
||||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
|
||||||
|
|
||||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
|
||||||
|
|
||||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw=="],
|
"@types/node": ["@types/node@20.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw=="],
|
||||||
@@ -84,7 +50,5 @@
|
|||||||
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
196
base-action/package-lock.json
generated
196
base-action/package-lock.json
generated
@@ -1,196 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@anthropic-ai/claude-code-base-action",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "@anthropic-ai/claude-code-base-action",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@actions/core": "^1.10.1",
|
|
||||||
"shell-quote": "^1.8.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "^1.2.12",
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"@types/shell-quote": "^1.7.5",
|
|
||||||
"prettier": "3.5.3",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@actions/core": {
|
|
||||||
"version": "1.11.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
|
|
||||||
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@actions/exec": "^1.1.1",
|
|
||||||
"@actions/http-client": "^2.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@actions/exec": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@actions/io": "^1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@actions/http-client": {
|
|
||||||
"version": "2.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz",
|
|
||||||
"integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tunnel": "^0.0.6",
|
|
||||||
"undici": "^5.25.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@actions/io": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@fastify/busboy": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/bun": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bun-types": "1.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "20.19.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
|
|
||||||
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~6.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
|
||||||
"version": "19.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"csstype": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/shell-quote": {
|
|
||||||
"version": "1.7.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz",
|
|
||||||
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/bun-types": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^19"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/csstype": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/prettier": {
|
|
||||||
"version": "3.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
|
|
||||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"prettier": "bin/prettier.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shell-quote": {
|
|
||||||
"version": "1.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
|
||||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tunnel": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici": {
|
|
||||||
"version": "5.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
|
||||||
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@fastify/busboy": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "6.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -16,11 +16,10 @@ async function run() {
|
|||||||
undefined, // homeDir
|
undefined, // homeDir
|
||||||
);
|
);
|
||||||
|
|
||||||
// Install Claude Code plugins if specified
|
// Install plugins if specified
|
||||||
await installPlugins(
|
await installPlugins(
|
||||||
process.env.INPUT_PLUGIN_MARKETPLACES,
|
|
||||||
process.env.INPUT_PLUGINS,
|
process.env.INPUT_PLUGINS,
|
||||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE || "claude",
|
||||||
);
|
);
|
||||||
|
|
||||||
const promptConfig = await preparePrompt({
|
const promptConfig = await preparePrompt({
|
||||||
@@ -41,7 +40,6 @@ async function run() {
|
|||||||
model: process.env.ANTHROPIC_MODEL,
|
model: process.env.ANTHROPIC_MODEL,
|
||||||
pathToClaudeCodeExecutable:
|
pathToClaudeCodeExecutable:
|
||||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||||
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setFailed(`Action failed with error: ${error}`);
|
core.setFailed(`Action failed with error: ${error}`);
|
||||||
|
|||||||
@@ -1,222 +1,80 @@
|
|||||||
import { spawn, ChildProcess } from "child_process";
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/;
|
import { spawn } from "child_process";
|
||||||
const MAX_PLUGIN_NAME_LENGTH = 512;
|
|
||||||
const PATH_TRAVERSAL_REGEX =
|
// Declare console as global for TypeScript
|
||||||
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/;
|
declare const console: {
|
||||||
const MARKETPLACE_URL_REGEX =
|
log: (message: string) => void;
|
||||||
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
|
error: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a marketplace URL for security issues
|
* Parses a comma-separated list of plugin names and returns an array of trimmed plugin names
|
||||||
* @param url - The marketplace URL to validate
|
|
||||||
* @throws {Error} If the URL is invalid
|
|
||||||
*/
|
*/
|
||||||
function validateMarketplaceUrl(url: string): void {
|
export function parsePlugins(pluginsInput: string | undefined): string[] {
|
||||||
const normalized = url.trim();
|
if (!pluginsInput || pluginsInput.trim() === "") {
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
throw new Error("Marketplace URL cannot be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
|
|
||||||
throw new Error(`Invalid marketplace URL format: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional check for valid URL structure
|
|
||||||
try {
|
|
||||||
new URL(normalized);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Invalid marketplace URL: ${url}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a plugin name for security issues
|
|
||||||
* @param pluginName - The plugin name to validate
|
|
||||||
* @throws {Error} If the plugin name is invalid
|
|
||||||
*/
|
|
||||||
function validatePluginName(pluginName: string): void {
|
|
||||||
// Normalize Unicode to prevent homoglyph attacks (e.g., fullwidth dots, Unicode slashes)
|
|
||||||
const normalized = pluginName.normalize("NFC");
|
|
||||||
|
|
||||||
if (normalized.length > MAX_PLUGIN_NAME_LENGTH) {
|
|
||||||
throw new Error(`Plugin name too long: ${normalized.substring(0, 50)}...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!PLUGIN_NAME_REGEX.test(normalized)) {
|
|
||||||
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent path traversal attacks with single efficient regex check
|
|
||||||
if (PATH_TRAVERSAL_REGEX.test(normalized)) {
|
|
||||||
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a newline-separated list of marketplace URLs and return an array of validated URLs
|
|
||||||
* @param marketplaces - Newline-separated list of marketplace Git URLs
|
|
||||||
* @returns Array of validated marketplace URLs (empty array if none provided)
|
|
||||||
*/
|
|
||||||
function parseMarketplaces(marketplaces?: string): string[] {
|
|
||||||
const trimmed = marketplaces?.trim();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by newline and process each URL
|
return pluginsInput
|
||||||
return trimmed
|
.split(",")
|
||||||
.split("\n")
|
.map((plugin) => plugin.trim())
|
||||||
.map((url) => url.trim())
|
.filter((plugin) => plugin.length > 0);
|
||||||
.filter((url) => {
|
|
||||||
if (url.length === 0) return false;
|
|
||||||
|
|
||||||
validateMarketplaceUrl(url);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a newline-separated list of plugin names and return an array of trimmed, non-empty plugin names
|
* Installs a single Claude Code plugin
|
||||||
* Validates plugin names to prevent command injection and path traversal attacks
|
|
||||||
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
|
|
||||||
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots
|
|
||||||
* @param plugins - Newline-separated list of plugin names, or undefined/empty to return empty array
|
|
||||||
* @returns Array of validated plugin names (empty array if none provided)
|
|
||||||
* @throws {Error} If any plugin name fails validation
|
|
||||||
*/
|
*/
|
||||||
function parsePlugins(plugins?: string): string[] {
|
export async function installPlugin(
|
||||||
const trimmedPlugins = plugins?.trim();
|
pluginName: string,
|
||||||
|
claudeExecutable: string = "claude",
|
||||||
if (!trimmedPlugins) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by newline and process each plugin
|
|
||||||
return trimmedPlugins
|
|
||||||
.split("\n")
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter((p) => {
|
|
||||||
if (p.length === 0) return false;
|
|
||||||
|
|
||||||
validatePluginName(p);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes a Claude Code CLI command with proper error handling
|
|
||||||
* @param claudeExecutable - Path to the Claude executable
|
|
||||||
* @param args - Command arguments to pass to the executable
|
|
||||||
* @param errorContext - Context string for error messages (e.g., "Failed to install plugin 'foo'")
|
|
||||||
* @returns Promise that resolves when the command completes successfully
|
|
||||||
* @throws {Error} If the command fails to execute
|
|
||||||
*/
|
|
||||||
async function executeClaudeCommand(
|
|
||||||
claudeExecutable: string,
|
|
||||||
args: string[],
|
|
||||||
errorContext: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const childProcess: ChildProcess = spawn(claudeExecutable, args, {
|
const process = spawn(claudeExecutable, ["plugin", "install", pluginName], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
|
|
||||||
childProcess.on("close", (code: number | null) => {
|
process.on("close", (code: number | null) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else if (code === null) {
|
|
||||||
reject(new Error(`${errorContext}: process terminated by signal`));
|
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`${errorContext} (exit code: ${code})`));
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Failed to install plugin '${pluginName}' (exit code: ${code})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
childProcess.on("error", (err: Error) => {
|
process.on("error", (err: Error) => {
|
||||||
reject(new Error(`${errorContext}: ${err.message}`));
|
reject(
|
||||||
|
new Error(`Failed to install plugin '${pluginName}': ${err.message}`),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs a single Claude Code plugin
|
* Installs Claude Code plugins from a comma-separated list
|
||||||
* @param pluginName - The name of the plugin to install
|
|
||||||
* @param claudeExecutable - Path to the Claude executable
|
|
||||||
* @returns Promise that resolves when the plugin is installed successfully
|
|
||||||
* @throws {Error} If the plugin installation fails
|
|
||||||
*/
|
|
||||||
async function installPlugin(
|
|
||||||
pluginName: string,
|
|
||||||
claudeExecutable: string,
|
|
||||||
): Promise<void> {
|
|
||||||
console.log(`Installing plugin: ${pluginName}`);
|
|
||||||
|
|
||||||
return executeClaudeCommand(
|
|
||||||
claudeExecutable,
|
|
||||||
["plugin", "install", pluginName],
|
|
||||||
`Failed to install plugin '${pluginName}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a Claude Code plugin marketplace
|
|
||||||
* @param claudeExecutable - Path to the Claude executable
|
|
||||||
* @param marketplaceUrl - The marketplace Git URL to add
|
|
||||||
* @returns Promise that resolves when the marketplace add command completes
|
|
||||||
* @throws {Error} If the command fails to execute
|
|
||||||
*/
|
|
||||||
async function addMarketplace(
|
|
||||||
claudeExecutable: string,
|
|
||||||
marketplaceUrl: string,
|
|
||||||
): Promise<void> {
|
|
||||||
console.log(`Adding marketplace: ${marketplaceUrl}`);
|
|
||||||
|
|
||||||
return executeClaudeCommand(
|
|
||||||
claudeExecutable,
|
|
||||||
["plugin", "marketplace", "add", marketplaceUrl],
|
|
||||||
`Failed to add marketplace '${marketplaceUrl}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs Claude Code plugins from a newline-separated list
|
|
||||||
* @param marketplacesInput - Newline-separated list of marketplace Git URLs
|
|
||||||
* @param pluginsInput - Newline-separated list of plugin names
|
|
||||||
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
|
||||||
* @returns Promise that resolves when all plugins are installed
|
|
||||||
* @throws {Error} If any plugin fails validation or installation (stops on first error)
|
|
||||||
*/
|
*/
|
||||||
export async function installPlugins(
|
export async function installPlugins(
|
||||||
marketplacesInput?: string,
|
pluginsInput: string | undefined,
|
||||||
pluginsInput?: string,
|
claudeExecutable: string = "claude",
|
||||||
claudeExecutable?: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Resolve executable path with explicit fallback
|
|
||||||
const resolvedExecutable = claudeExecutable || "claude";
|
|
||||||
|
|
||||||
// Parse and add all marketplaces before installing plugins
|
|
||||||
const marketplaces = parseMarketplaces(marketplacesInput);
|
|
||||||
|
|
||||||
if (marketplaces.length > 0) {
|
|
||||||
console.log(`Adding ${marketplaces.length} marketplace(s)...`);
|
|
||||||
for (const marketplace of marketplaces) {
|
|
||||||
await addMarketplace(resolvedExecutable, marketplace);
|
|
||||||
console.log(`✓ Successfully added marketplace: ${marketplace}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("No marketplaces specified, skipping marketplace setup");
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = parsePlugins(pluginsInput);
|
const plugins = parsePlugins(pluginsInput);
|
||||||
if (plugins.length > 0) {
|
|
||||||
console.log(`Installing ${plugins.length} plugin(s)...`);
|
if (plugins.length === 0) {
|
||||||
for (const plugin of plugins) {
|
console.log("No plugins to install");
|
||||||
await installPlugin(plugin, resolvedExecutable);
|
return;
|
||||||
console.log(`✓ Successfully installed: ${plugin}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("No plugins specified, skipping plugins installation");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Installing ${plugins.length} plugin(s)...`);
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
console.log(`Installing plugin: ${plugin}`);
|
||||||
|
await installPlugin(plugin, claudeExecutable);
|
||||||
|
console.log(`✓ Successfully installed: ${plugin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All plugins installed successfully");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
import { parse as parseShellArgs } from "shell-quote";
|
|
||||||
import type { ClaudeOptions } from "./run-claude";
|
|
||||||
import type { Options as SdkOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of parsing ClaudeOptions for SDK usage
|
|
||||||
*/
|
|
||||||
export type ParsedSdkOptions = {
|
|
||||||
sdkOptions: SdkOptions;
|
|
||||||
showFullOutput: boolean;
|
|
||||||
hasJsonSchema: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Flags that should accumulate multiple values instead of overwriting
|
|
||||||
// Include both camelCase and hyphenated variants for CLI compatibility
|
|
||||||
const ACCUMULATING_FLAGS = new Set([
|
|
||||||
"allowedTools",
|
|
||||||
"allowed-tools",
|
|
||||||
"disallowedTools",
|
|
||||||
"disallowed-tools",
|
|
||||||
"mcp-config",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Delimiter used to join accumulated flag values
|
|
||||||
const ACCUMULATE_DELIMITER = "\x00";
|
|
||||||
|
|
||||||
type McpConfig = {
|
|
||||||
mcpServers?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge multiple MCP config values into a single config.
|
|
||||||
* Each config can be a JSON string or a file path.
|
|
||||||
* For JSON strings, mcpServers objects are merged.
|
|
||||||
* For file paths, they are kept as-is (user's file takes precedence and is used last).
|
|
||||||
*/
|
|
||||||
function mergeMcpConfigs(configValues: string[]): string {
|
|
||||||
const merged: McpConfig = { mcpServers: {} };
|
|
||||||
let lastFilePath: string | null = null;
|
|
||||||
|
|
||||||
for (const config of configValues) {
|
|
||||||
const trimmed = config.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
|
|
||||||
// Check if it's a JSON string (starts with {) or a file path
|
|
||||||
if (trimmed.startsWith("{")) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed) as McpConfig;
|
|
||||||
if (parsed.mcpServers) {
|
|
||||||
Object.assign(merged.mcpServers!, parsed.mcpServers);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If JSON parsing fails, treat as file path
|
|
||||||
lastFilePath = trimmed;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// It's a file path - store it to handle separately
|
|
||||||
lastFilePath = trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have file paths, we need to keep the merged JSON and let the file
|
|
||||||
// be handled separately. Since we can only return one value, merge what we can.
|
|
||||||
// If there's a file path, we need a different approach - read the file at runtime.
|
|
||||||
// For now, if there's a file path, we'll stringify the merged config.
|
|
||||||
// The action prepends its config as JSON, so we can safely merge inline JSON configs.
|
|
||||||
|
|
||||||
// If no inline configs were found (all file paths), return the last file path
|
|
||||||
if (Object.keys(merged.mcpServers!).length === 0 && lastFilePath) {
|
|
||||||
return lastFilePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: If user passes a file path, we cannot merge it at parse time since
|
|
||||||
// we don't have access to the file system here. The action's built-in MCP
|
|
||||||
// servers are always passed as inline JSON, so they will be merged.
|
|
||||||
// If user also passes inline JSON, it will be merged.
|
|
||||||
// If user passes a file path, they should ensure it includes all needed servers.
|
|
||||||
|
|
||||||
return JSON.stringify(merged);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse claudeArgs string into extraArgs record for SDK pass-through
|
|
||||||
* The SDK/CLI will handle --mcp-config, --json-schema, etc.
|
|
||||||
* For allowedTools and disallowedTools, multiple occurrences are accumulated (null-char joined).
|
|
||||||
* Accumulating flags also consume all consecutive non-flag values
|
|
||||||
* (e.g., --allowed-tools "Tool1" "Tool2" "Tool3" captures all three).
|
|
||||||
*/
|
|
||||||
function parseClaudeArgsToExtraArgs(
|
|
||||||
claudeArgs?: string,
|
|
||||||
): Record<string, string | null> {
|
|
||||||
if (!claudeArgs?.trim()) return {};
|
|
||||||
|
|
||||||
const result: Record<string, string | null> = {};
|
|
||||||
const args = parseShellArgs(claudeArgs).filter(
|
|
||||||
(arg): arg is string => typeof arg === "string",
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
const arg = args[i];
|
|
||||||
if (arg?.startsWith("--")) {
|
|
||||||
const flag = arg.slice(2);
|
|
||||||
const nextArg = args[i + 1];
|
|
||||||
|
|
||||||
// Check if next arg is a value (not another flag)
|
|
||||||
if (nextArg && !nextArg.startsWith("--")) {
|
|
||||||
// For accumulating flags, consume all consecutive non-flag values
|
|
||||||
// This handles: --allowed-tools "Tool1" "Tool2" "Tool3"
|
|
||||||
if (ACCUMULATING_FLAGS.has(flag)) {
|
|
||||||
const values: string[] = [];
|
|
||||||
while (i + 1 < args.length && !args[i + 1]?.startsWith("--")) {
|
|
||||||
i++;
|
|
||||||
values.push(args[i]!);
|
|
||||||
}
|
|
||||||
const joinedValues = values.join(ACCUMULATE_DELIMITER);
|
|
||||||
if (result[flag]) {
|
|
||||||
result[flag] =
|
|
||||||
`${result[flag]}${ACCUMULATE_DELIMITER}${joinedValues}`;
|
|
||||||
} else {
|
|
||||||
result[flag] = joinedValues;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result[flag] = nextArg;
|
|
||||||
i++; // Skip the value
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result[flag] = null; // Boolean flag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse ClaudeOptions into SDK-compatible options
|
|
||||||
* Uses extraArgs for CLI pass-through instead of duplicating option parsing
|
|
||||||
*/
|
|
||||||
export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|
||||||
// Determine output verbosity
|
|
||||||
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
|
|
||||||
const showFullOutput = options.showFullOutput === "true" || isDebugMode;
|
|
||||||
|
|
||||||
// Parse claudeArgs into extraArgs for CLI pass-through
|
|
||||||
const extraArgs = parseClaudeArgsToExtraArgs(options.claudeArgs);
|
|
||||||
|
|
||||||
// Detect if --json-schema is present (for hasJsonSchema flag)
|
|
||||||
const hasJsonSchema = "json-schema" in extraArgs;
|
|
||||||
|
|
||||||
// Extract and merge allowedTools from all sources:
|
|
||||||
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
|
|
||||||
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
|
|
||||||
// 2. From options.allowedTools (direct input - may be undefined)
|
|
||||||
// This prevents duplicate flags being overwritten when claudeArgs contains --allowedTools
|
|
||||||
const allowedToolsValues = [
|
|
||||||
extraArgs["allowedTools"],
|
|
||||||
extraArgs["allowed-tools"],
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(ACCUMULATE_DELIMITER);
|
|
||||||
const extraArgsAllowedTools = allowedToolsValues
|
|
||||||
? allowedToolsValues
|
|
||||||
.split(ACCUMULATE_DELIMITER)
|
|
||||||
.flatMap((v) => v.split(","))
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const directAllowedTools = options.allowedTools
|
|
||||||
? options.allowedTools.split(",").map((t) => t.trim())
|
|
||||||
: [];
|
|
||||||
const mergedAllowedTools = [
|
|
||||||
...new Set([...extraArgsAllowedTools, ...directAllowedTools]),
|
|
||||||
];
|
|
||||||
delete extraArgs["allowedTools"];
|
|
||||||
delete extraArgs["allowed-tools"];
|
|
||||||
|
|
||||||
// Same for disallowedTools - check both camelCase and hyphenated variants
|
|
||||||
const disallowedToolsValues = [
|
|
||||||
extraArgs["disallowedTools"],
|
|
||||||
extraArgs["disallowed-tools"],
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(ACCUMULATE_DELIMITER);
|
|
||||||
const extraArgsDisallowedTools = disallowedToolsValues
|
|
||||||
? disallowedToolsValues
|
|
||||||
.split(ACCUMULATE_DELIMITER)
|
|
||||||
.flatMap((v) => v.split(","))
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const directDisallowedTools = options.disallowedTools
|
|
||||||
? options.disallowedTools.split(",").map((t) => t.trim())
|
|
||||||
: [];
|
|
||||||
const mergedDisallowedTools = [
|
|
||||||
...new Set([...extraArgsDisallowedTools, ...directDisallowedTools]),
|
|
||||||
];
|
|
||||||
delete extraArgs["disallowedTools"];
|
|
||||||
delete extraArgs["disallowed-tools"];
|
|
||||||
|
|
||||||
// Merge multiple --mcp-config values by combining their mcpServers objects
|
|
||||||
// The action prepends its config (github_comment, github_ci, etc.) as inline JSON,
|
|
||||||
// and users may provide their own config as inline JSON or file path
|
|
||||||
if (extraArgs["mcp-config"]) {
|
|
||||||
const mcpConfigValues = extraArgs["mcp-config"].split(ACCUMULATE_DELIMITER);
|
|
||||||
if (mcpConfigValues.length > 1) {
|
|
||||||
extraArgs["mcp-config"] = mergeMcpConfigs(mcpConfigValues);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build custom environment
|
|
||||||
const env: Record<string, string | undefined> = { ...process.env };
|
|
||||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
|
||||||
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build system prompt option - default to claude_code preset
|
|
||||||
let systemPrompt: SdkOptions["systemPrompt"];
|
|
||||||
if (options.systemPrompt) {
|
|
||||||
systemPrompt = options.systemPrompt;
|
|
||||||
} else if (options.appendSystemPrompt) {
|
|
||||||
systemPrompt = {
|
|
||||||
type: "preset",
|
|
||||||
preset: "claude_code",
|
|
||||||
append: options.appendSystemPrompt,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Default to claude_code preset when no custom prompt is specified
|
|
||||||
systemPrompt = {
|
|
||||||
type: "preset",
|
|
||||||
preset: "claude_code",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build SDK options - use merged tools from both direct options and claudeArgs
|
|
||||||
const sdkOptions: SdkOptions = {
|
|
||||||
// Direct options from ClaudeOptions inputs
|
|
||||||
model: options.model,
|
|
||||||
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
|
||||||
allowedTools:
|
|
||||||
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
|
||||||
disallowedTools:
|
|
||||||
mergedDisallowedTools.length > 0 ? mergedDisallowedTools : undefined,
|
|
||||||
systemPrompt,
|
|
||||||
fallbackModel: options.fallbackModel,
|
|
||||||
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
|
||||||
|
|
||||||
// Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc.
|
|
||||||
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates
|
|
||||||
extraArgs,
|
|
||||||
env,
|
|
||||||
|
|
||||||
// Load settings from sources - prefer user's --setting-sources if provided, otherwise use all sources
|
|
||||||
// This ensures users can override the default behavior (e.g., --setting-sources user to avoid in-repo configs)
|
|
||||||
settingSources: extraArgs["setting-sources"]
|
|
||||||
? (extraArgs["setting-sources"].split(
|
|
||||||
",",
|
|
||||||
) as SdkOptions["settingSources"])
|
|
||||||
: ["user", "project", "local"],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove setting-sources from extraArgs to avoid passing it twice
|
|
||||||
delete extraArgs["setting-sources"];
|
|
||||||
|
|
||||||
return {
|
|
||||||
sdkOptions,
|
|
||||||
showFullOutput,
|
|
||||||
hasJsonSchema,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import * as core from "@actions/core";
|
|
||||||
import { readFile, writeFile } from "fs/promises";
|
|
||||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import type {
|
|
||||||
SDKMessage,
|
|
||||||
SDKResultMessage,
|
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
|
||||||
|
|
||||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes SDK output to match CLI sanitization behavior
|
|
||||||
*/
|
|
||||||
function sanitizeSdkOutput(
|
|
||||||
message: SDKMessage,
|
|
||||||
showFullOutput: boolean,
|
|
||||||
): string | null {
|
|
||||||
if (showFullOutput) {
|
|
||||||
return JSON.stringify(message, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// System initialization - safe to show
|
|
||||||
if (message.type === "system" && message.subtype === "init") {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
type: "system",
|
|
||||||
subtype: "init",
|
|
||||||
message: "Claude Code initialized",
|
|
||||||
model: "model" in message ? message.model : "unknown",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result messages - show sanitized summary
|
|
||||||
if (message.type === "result") {
|
|
||||||
const resultMsg = message as SDKResultMessage;
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
type: "result",
|
|
||||||
subtype: resultMsg.subtype,
|
|
||||||
is_error: resultMsg.is_error,
|
|
||||||
duration_ms: resultMsg.duration_ms,
|
|
||||||
num_turns: resultMsg.num_turns,
|
|
||||||
total_cost_usd: resultMsg.total_cost_usd,
|
|
||||||
permission_denials: resultMsg.permission_denials,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress other message types in non-full-output mode
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Claude using the Agent SDK
|
|
||||||
*/
|
|
||||||
export async function runClaudeWithSdk(
|
|
||||||
promptPath: string,
|
|
||||||
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
const prompt = await readFile(promptPath, "utf-8");
|
|
||||||
|
|
||||||
if (!showFullOutput) {
|
|
||||||
console.log(
|
|
||||||
"Running Claude Code via SDK (full output hidden for security)...",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Running Claude with prompt from file: ${promptPath}`);
|
|
||||||
// Log SDK options without env (which could contain sensitive data)
|
|
||||||
const { env, ...optionsToLog } = sdkOptions;
|
|
||||||
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
|
|
||||||
|
|
||||||
const messages: SDKMessage[] = [];
|
|
||||||
let resultMessage: SDKResultMessage | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const message of query({ prompt, options: sdkOptions })) {
|
|
||||||
messages.push(message);
|
|
||||||
|
|
||||||
const sanitized = sanitizeSdkOutput(message, showFullOutput);
|
|
||||||
if (sanitized) {
|
|
||||||
console.log(sanitized);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === "result") {
|
|
||||||
resultMessage = message as SDKResultMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("SDK execution error:", error);
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write execution file
|
|
||||||
try {
|
|
||||||
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
|
||||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
|
||||||
} catch (error) {
|
|
||||||
core.warning(`Failed to write execution file: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resultMessage) {
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
core.error("No result message received from Claude");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSuccess = resultMessage.subtype === "success";
|
|
||||||
core.setOutput("conclusion", isSuccess ? "success" : "failure");
|
|
||||||
|
|
||||||
// Handle structured output
|
|
||||||
if (hasJsonSchema) {
|
|
||||||
if (
|
|
||||||
isSuccess &&
|
|
||||||
"structured_output" in resultMessage &&
|
|
||||||
resultMessage.structured_output
|
|
||||||
) {
|
|
||||||
const structuredOutputJson = JSON.stringify(
|
|
||||||
resultMessage.structured_output,
|
|
||||||
);
|
|
||||||
core.setOutput("structured_output", structuredOutputJson);
|
|
||||||
core.info(
|
|
||||||
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
core.setFailed(
|
|
||||||
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
|
|
||||||
);
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSuccess) {
|
|
||||||
if ("errors" in resultMessage && resultMessage.errors) {
|
|
||||||
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { unlink, writeFile, stat, readFile } from "fs/promises";
|
import { unlink, writeFile, stat } 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 { parseSdkOptions } from "./parse-sdk-options";
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -14,59 +12,6 @@ const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
|
|||||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||||
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
|
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes JSON output to remove sensitive information when full output is disabled
|
|
||||||
* Returns a safe summary message or null if the message should be completely suppressed
|
|
||||||
*/
|
|
||||||
function sanitizeJsonOutput(
|
|
||||||
jsonObj: any,
|
|
||||||
showFullOutput: boolean,
|
|
||||||
): string | null {
|
|
||||||
if (showFullOutput) {
|
|
||||||
// In full output mode, return the full JSON
|
|
||||||
return JSON.stringify(jsonObj, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In non-full-output mode, provide minimal safe output
|
|
||||||
const type = jsonObj.type;
|
|
||||||
const subtype = jsonObj.subtype;
|
|
||||||
|
|
||||||
// System initialization - safe to show
|
|
||||||
if (type === "system" && subtype === "init") {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
type: "system",
|
|
||||||
subtype: "init",
|
|
||||||
message: "Claude Code initialized",
|
|
||||||
model: jsonObj.model || "unknown",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result messages - Always show the final result
|
|
||||||
if (type === "result") {
|
|
||||||
// These messages contain the final result and should always be visible
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
type: "result",
|
|
||||||
subtype: jsonObj.subtype,
|
|
||||||
is_error: jsonObj.is_error,
|
|
||||||
duration_ms: jsonObj.duration_ms,
|
|
||||||
num_turns: jsonObj.num_turns,
|
|
||||||
total_cost_usd: jsonObj.total_cost_usd,
|
|
||||||
permission_denials: jsonObj.permission_denials,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For any other message types, suppress completely in non-full-output mode
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClaudeOptions = {
|
export type ClaudeOptions = {
|
||||||
claudeArgs?: string;
|
claudeArgs?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -79,7 +24,6 @@ export type ClaudeOptions = {
|
|||||||
appendSystemPrompt?: string;
|
appendSystemPrompt?: string;
|
||||||
claudeEnv?: string;
|
claudeEnv?: string;
|
||||||
fallbackModel?: string;
|
fallbackModel?: string;
|
||||||
showFullOutput?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreparedConfig = {
|
type PreparedConfig = {
|
||||||
@@ -124,95 +68,9 @@ export function prepareRunConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses session_id from execution file and sets GitHub Action output
|
|
||||||
* Exported for testing
|
|
||||||
*/
|
|
||||||
export async function parseAndSetSessionId(
|
|
||||||
executionFile: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const content = await readFile(executionFile, "utf-8");
|
|
||||||
const messages = JSON.parse(content) as {
|
|
||||||
type: string;
|
|
||||||
subtype?: string;
|
|
||||||
session_id?: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
// Find the system.init message which contains session_id
|
|
||||||
const initMessage = messages.find(
|
|
||||||
(m) => m.type === "system" && m.subtype === "init",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (initMessage?.session_id) {
|
|
||||||
core.setOutput("session_id", initMessage.session_id);
|
|
||||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Don't fail the action if session_id extraction fails
|
|
||||||
core.warning(`Failed to extract session_id: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses structured_output from execution file and sets GitHub Action outputs
|
|
||||||
* Only runs if --json-schema was explicitly provided in claude_args
|
|
||||||
* Exported for testing
|
|
||||||
*/
|
|
||||||
export async function parseAndSetStructuredOutputs(
|
|
||||||
executionFile: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const content = await readFile(executionFile, "utf-8");
|
|
||||||
const messages = JSON.parse(content) as {
|
|
||||||
type: string;
|
|
||||||
structured_output?: Record<string, unknown>;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
// Search backwards - result is typically last or second-to-last message
|
|
||||||
const result = messages.findLast(
|
|
||||||
(m) => m.type === "result" && m.structured_output,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result?.structured_output) {
|
|
||||||
throw new Error(
|
|
||||||
`--json-schema was provided but Claude did not return structured_output.\n` +
|
|
||||||
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the complete structured output as a single JSON string
|
|
||||||
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
|
|
||||||
const structuredOutputJson = JSON.stringify(result.structured_output);
|
|
||||||
core.setOutput("structured_output", structuredOutputJson);
|
|
||||||
core.info(
|
|
||||||
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw error; // Preserve original error and stack trace
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to parse structured outputs: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||||
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
|
|
||||||
const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
|
|
||||||
console.log(
|
|
||||||
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (useAgentSdk) {
|
|
||||||
const parsedOptions = parseSdkOptions(options);
|
|
||||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = prepareRunConfig(promptPath, options);
|
const config = prepareRunConfig(promptPath, options);
|
||||||
|
|
||||||
// Detect if --json-schema is present in claude args
|
|
||||||
const hasJsonSchema = options.claudeArgs?.includes("--json-schema") ?? false;
|
|
||||||
|
|
||||||
// Create a named pipe
|
// Create a named pipe
|
||||||
try {
|
try {
|
||||||
await unlink(PIPE_PATH);
|
await unlink(PIPE_PATH);
|
||||||
@@ -280,27 +138,12 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|||||||
pipeStream.destroy();
|
pipeStream.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine if full output should be shown
|
|
||||||
// Show full output if explicitly set to "true" OR if GitHub Actions debug mode is enabled
|
|
||||||
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
|
|
||||||
let showFullOutput = options.showFullOutput === "true" || isDebugMode;
|
|
||||||
|
|
||||||
if (isDebugMode && options.showFullOutput !== "false") {
|
|
||||||
console.log("Debug mode detected - showing full output");
|
|
||||||
showFullOutput = true;
|
|
||||||
} else if (!showFullOutput) {
|
|
||||||
console.log("Running Claude Code (full output hidden for security)...");
|
|
||||||
console.log(
|
|
||||||
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture output for parsing execution metrics
|
// Capture output for parsing execution metrics
|
||||||
let output = "";
|
let output = "";
|
||||||
claudeProcess.stdout.on("data", (data) => {
|
claudeProcess.stdout.on("data", (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
|
|
||||||
// Try to parse as JSON and handle based on verbose setting
|
// Try to parse as JSON and pretty print if it's on a single line
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
lines.forEach((line: string, index: number) => {
|
lines.forEach((line: string, index: number) => {
|
||||||
if (line.trim() === "") return;
|
if (line.trim() === "") return;
|
||||||
@@ -308,24 +151,17 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|||||||
try {
|
try {
|
||||||
// Check if this line is a JSON object
|
// Check if this line is a JSON object
|
||||||
const parsed = JSON.parse(line);
|
const parsed = JSON.parse(line);
|
||||||
const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
|
const prettyJson = JSON.stringify(parsed, null, 2);
|
||||||
|
process.stdout.write(prettyJson);
|
||||||
if (sanitizedOutput) {
|
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||||
process.stdout.write(sanitizedOutput);
|
process.stdout.write("\n");
|
||||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not a JSON object
|
// Not a JSON object, print as is
|
||||||
if (showFullOutput) {
|
process.stdout.write(line);
|
||||||
// In full output mode, print as is
|
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||||
process.stdout.write(line);
|
process.stdout.write("\n");
|
||||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// In non-full-output mode, suppress non-JSON output
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,26 +232,8 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|||||||
core.warning(`Failed to process output for execution metrics: ${e}`);
|
core.warning(`Failed to process output for execution metrics: ${e}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
|
||||||
|
|
||||||
// Extract and set session_id
|
|
||||||
await parseAndSetSessionId(EXECUTION_FILE);
|
|
||||||
|
|
||||||
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
|
||||||
if (hasJsonSchema) {
|
|
||||||
try {
|
|
||||||
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
core.setFailed(errorMessage);
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set conclusion to success if we reached here
|
|
||||||
core.setOutput("conclusion", "success");
|
core.setOutput("conclusion", "success");
|
||||||
|
core.setOutput("execution_file", EXECUTION_FILE);
|
||||||
} else {
|
} else {
|
||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* Validates the environment variables required for running Claude Code
|
* Validates the environment variables required for running Claude Code
|
||||||
* based on the selected provider (Anthropic API, AWS Bedrock, Google Vertex AI, or Microsoft Foundry)
|
* based on the selected provider (Anthropic API, AWS Bedrock, or Google Vertex AI)
|
||||||
*/
|
*/
|
||||||
export function validateEnvironmentVariables() {
|
export function validateEnvironmentVariables() {
|
||||||
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === "1";
|
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === "1";
|
||||||
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1";
|
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1";
|
||||||
const useFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY === "1";
|
|
||||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Check for mutual exclusivity between providers
|
if (useBedrock && useVertex) {
|
||||||
const activeProviders = [useBedrock, useVertex, useFoundry].filter(Boolean);
|
|
||||||
if (activeProviders.length > 1) {
|
|
||||||
errors.push(
|
errors.push(
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useBedrock && !useVertex && !useFoundry) {
|
if (!useBedrock && !useVertex) {
|
||||||
if (!anthropicApiKey && !claudeCodeOAuthToken) {
|
if (!anthropicApiKey && !claudeCodeOAuthToken) {
|
||||||
errors.push(
|
errors.push(
|
||||||
"Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.",
|
"Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (useBedrock) {
|
} else if (useBedrock) {
|
||||||
const awsRegion = process.env.AWS_REGION;
|
const requiredBedrockVars = {
|
||||||
const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
AWS_REGION: process.env.AWS_REGION,
|
||||||
const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||||
const awsBearerToken = process.env.AWS_BEARER_TOKEN_BEDROCK;
|
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
// AWS_REGION is always required for Bedrock
|
Object.entries(requiredBedrockVars).forEach(([key, value]) => {
|
||||||
if (!awsRegion) {
|
if (!value) {
|
||||||
errors.push("AWS_REGION is required when using AWS Bedrock.");
|
errors.push(`${key} is required when using AWS Bedrock.`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Either bearer token OR access key credentials must be provided
|
|
||||||
const hasAccessKeyCredentials = awsAccessKeyId && awsSecretAccessKey;
|
|
||||||
const hasBearerToken = awsBearerToken;
|
|
||||||
|
|
||||||
if (!hasAccessKeyCredentials && !hasBearerToken) {
|
|
||||||
errors.push(
|
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (useVertex) {
|
} else if (useVertex) {
|
||||||
const requiredVertexVars = {
|
const requiredVertexVars = {
|
||||||
ANTHROPIC_VERTEX_PROJECT_ID: process.env.ANTHROPIC_VERTEX_PROJECT_ID,
|
ANTHROPIC_VERTEX_PROJECT_ID: process.env.ANTHROPIC_VERTEX_PROJECT_ID,
|
||||||
@@ -56,16 +45,6 @@ export function validateEnvironmentVariables() {
|
|||||||
errors.push(`${key} is required when using Google Vertex AI.`);
|
errors.push(`${key} is required when using Google Vertex AI.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (useFoundry) {
|
|
||||||
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE;
|
|
||||||
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL;
|
|
||||||
|
|
||||||
// Either resource name or base URL is required
|
|
||||||
if (!foundryResource && !foundryBaseUrl) {
|
|
||||||
errors.push(
|
|
||||||
"Either ANTHROPIC_FOUNDRY_RESOURCE or ANTHROPIC_FOUNDRY_BASE_URL is required when using Microsoft Foundry.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@@ -1,599 +1,84 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { installPlugins } from "../src/install-plugins";
|
import { parsePlugins } from "../src/install-plugins";
|
||||||
import * as childProcess from "child_process";
|
|
||||||
|
|
||||||
describe("installPlugins", () => {
|
describe("parsePlugins", () => {
|
||||||
let spawnSpy: ReturnType<typeof spyOn> | undefined;
|
test("should return empty array for undefined input", () => {
|
||||||
|
expect(parsePlugins(undefined)).toEqual([]);
|
||||||
afterEach(() => {
|
|
||||||
// Restore original spawn after each test
|
|
||||||
if (spawnSpy) {
|
|
||||||
spawnSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function createMockSpawn(
|
test("should return empty array for empty string", () => {
|
||||||
exitCode: number | null = 0,
|
expect(parsePlugins("")).toEqual([]);
|
||||||
shouldError: boolean = false,
|
|
||||||
) {
|
|
||||||
const mockProcess = {
|
|
||||||
on: mock((event: string, handler: Function) => {
|
|
||||||
if (event === "close" && !shouldError) {
|
|
||||||
// Simulate successful close
|
|
||||||
setTimeout(() => handler(exitCode), 0);
|
|
||||||
} else if (event === "error" && shouldError) {
|
|
||||||
// Simulate error
|
|
||||||
setTimeout(() => handler(new Error("spawn error")), 0);
|
|
||||||
}
|
|
||||||
return mockProcess;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
spawnSpy = spyOn(childProcess, "spawn").mockImplementation(
|
|
||||||
() => mockProcess as any,
|
|
||||||
);
|
|
||||||
return spawnSpy;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("should not call spawn when no plugins are specified", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(undefined, "");
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not call spawn when plugins is undefined", async () => {
|
test("should return empty array for whitespace-only string", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins(" \n\t ")).toEqual([]);
|
||||||
await installPlugins(undefined, undefined);
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not call spawn when plugins is only whitespace", async () => {
|
test("should parse single plugin", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins("feature-dev")).toEqual(["feature-dev"]);
|
||||||
await installPlugins(undefined, " ");
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should install a single plugin with default executable", async () => {
|
test("should parse multiple plugins", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins("feature-dev,test-coverage-reviewer")).toEqual([
|
||||||
await installPlugins(undefined, "test-plugin");
|
"feature-dev",
|
||||||
|
"test-coverage-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
]);
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should install multiple plugins sequentially", async () => {
|
test("should trim whitespace around plugin names", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins(" feature-dev , test-coverage-reviewer ")).toEqual([
|
||||||
await installPlugins(undefined, "plugin1\nplugin2\nplugin3");
|
"feature-dev",
|
||||||
|
"test-coverage-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
]);
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin3"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should use custom claude executable path when provided", async () => {
|
test("should handle spaces between commas", () => {
|
||||||
const spy = createMockSpawn();
|
expect(
|
||||||
await installPlugins(undefined, "test-plugin", "/custom/path/to/claude");
|
parsePlugins(
|
||||||
|
"feature-dev, test-coverage-reviewer, code-quality-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"/custom/path/to/claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should trim whitespace from plugin names before installation", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(undefined, " plugin1 \n plugin2 ");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip empty entries in plugin list", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(undefined, "plugin1\n\nplugin2");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle plugin installation error and throw", async () => {
|
|
||||||
createMockSpawn(1, false); // Exit code 1
|
|
||||||
|
|
||||||
await expect(installPlugins(undefined, "failing-plugin")).rejects.toThrow(
|
|
||||||
"Failed to install plugin 'failing-plugin' (exit code: 1)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle null exit code (process terminated by signal)", async () => {
|
|
||||||
createMockSpawn(null, false); // Exit code null (terminated by signal)
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins(undefined, "terminated-plugin"),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"Failed to install plugin 'terminated-plugin': process terminated by signal",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should stop installation on first error", async () => {
|
|
||||||
const spy = createMockSpawn(1, false); // Exit code 1
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins(undefined, "plugin1\nplugin2\nplugin3"),
|
|
||||||
).rejects.toThrow("Failed to install plugin 'plugin1' (exit code: 1)");
|
|
||||||
|
|
||||||
// Should only try to install first plugin before failing
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle plugins with special characters in names", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(undefined, "org/plugin-name\n@scope/plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "org/plugin-name"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "@scope/plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle spawn errors", async () => {
|
|
||||||
createMockSpawn(0, true); // Trigger error event
|
|
||||||
|
|
||||||
await expect(installPlugins(undefined, "test-plugin")).rejects.toThrow(
|
|
||||||
"Failed to install plugin 'test-plugin': spawn error",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should install plugins with custom executable and multiple plugins", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
undefined,
|
|
||||||
"plugin-a\nplugin-b",
|
|
||||||
"/usr/local/bin/claude-custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"/usr/local/bin/claude-custom",
|
|
||||||
["plugin", "install", "plugin-a"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"/usr/local/bin/claude-custom",
|
|
||||||
["plugin", "install", "plugin-b"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with command injection attempts", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
// Should throw due to invalid characters (semicolon and spaces)
|
|
||||||
await expect(
|
|
||||||
installPlugins(undefined, "plugin-name; rm -rf /"),
|
|
||||||
).rejects.toThrow("Invalid plugin name format");
|
|
||||||
|
|
||||||
// Mock should never be called because validation fails first
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with path traversal using ../", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins(undefined, "../../../malicious-plugin"),
|
|
||||||
).rejects.toThrow("Invalid plugin name format");
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with path traversal using ./", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins(undefined, "./../../@scope/package"),
|
|
||||||
).rejects.toThrow("Invalid plugin name format");
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with consecutive dots", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins(undefined, ".../.../package")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject plugin names with hidden path traversal", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept plugin names with single dots in version numbers", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(undefined, "plugin-v1.0.2");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin-v1.0.2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept plugin names with multiple dots in semantic versions", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(undefined, "@scope/plugin-v1.0.0-beta.1");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "@scope/plugin-v1.0.0-beta.1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject Unicode homoglyph path traversal attempts", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
|
|
||||||
await expect(installPlugins(undefined, "../malicious")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject path traversal at end of path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins(undefined, "package/..")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject single dot directory reference", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins(undefined, "package/.")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject path traversal in middle of path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
|
|
||||||
"Invalid plugin name format",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Marketplace functionality tests
|
|
||||||
test("should add a single marketplace before installing plugins", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
"https://github.com/user/marketplace.git",
|
|
||||||
"test-plugin",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/user/marketplace.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
// Second call: install plugin
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should add multiple marketplaces with newline separation", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
"https://github.com/user/m1.git\nhttps://github.com/user/m2.git",
|
|
||||||
"test-plugin",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces + 1 plugin
|
|
||||||
// First two calls: add marketplaces
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "https://github.com/user/m1.git"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
// Third call: install plugin
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should add marketplaces before installing multiple plugins", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
"https://github.com/user/marketplace.git",
|
|
||||||
"plugin1\nplugin2",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3); // 1 marketplace + 2 plugins
|
|
||||||
// First call: add marketplace
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/user/marketplace.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
// Next calls: install plugins
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin1"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "plugin2"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle only marketplaces without plugins", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("https://github.com/user/marketplace.git", undefined);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/user/marketplace.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip empty marketplace entries", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
"https://github.com/user/m1.git\n\nhttps://github.com/user/m2.git",
|
|
||||||
"test-plugin",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces (skip empty) + 1 plugin
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should trim whitespace from marketplace URLs", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
" https://github.com/user/marketplace.git \n https://github.com/user/m2.git ",
|
|
||||||
"test-plugin",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
[
|
|
||||||
"plugin",
|
|
||||||
"marketplace",
|
|
||||||
"add",
|
|
||||||
"https://github.com/user/marketplace.git",
|
|
||||||
],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject invalid marketplace URL format", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins("not-a-valid-url", "test-plugin"),
|
|
||||||
).rejects.toThrow("Invalid marketplace URL format");
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject marketplace URL without .git extension", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins("https://github.com/user/marketplace", "test-plugin"),
|
|
||||||
).rejects.toThrow("Invalid marketplace URL format");
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject marketplace URL with non-https protocol", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins("http://github.com/user/marketplace.git", "test-plugin"),
|
|
||||||
).rejects.toThrow("Invalid marketplace URL format");
|
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip whitespace-only marketplace input", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(" ", "test-plugin");
|
|
||||||
|
|
||||||
// Should skip marketplaces and only install plugin
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle marketplace addition error", async () => {
|
|
||||||
createMockSpawn(1, false); // Exit code 1
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins("https://github.com/user/marketplace.git", "test-plugin"),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"Failed to add marketplace 'https://github.com/user/marketplace.git' (exit code: 1)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should stop if marketplace addition fails before installing plugins", async () => {
|
|
||||||
const spy = createMockSpawn(1, false); // Exit code 1
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
installPlugins(
|
|
||||||
"https://github.com/user/marketplace.git",
|
|
||||||
"plugin1\nplugin2",
|
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Failed to add marketplace");
|
).toEqual([
|
||||||
|
"feature-dev",
|
||||||
// Should only try to add marketplace, not install any plugins
|
"test-coverage-reviewer",
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
"code-quality-reviewer",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should use custom executable for marketplace operations", async () => {
|
test("should filter out empty values from consecutive commas", () => {
|
||||||
const spy = createMockSpawn();
|
expect(parsePlugins("feature-dev,,test-coverage-reviewer")).toEqual([
|
||||||
await installPlugins(
|
"feature-dev",
|
||||||
"https://github.com/user/marketplace.git",
|
"test-coverage-reviewer",
|
||||||
"test-plugin",
|
]);
|
||||||
"/custom/path/to/claude",
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
test("should handle trailing comma", () => {
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
expect(parsePlugins("feature-dev,test-coverage-reviewer,")).toEqual([
|
||||||
1,
|
"feature-dev",
|
||||||
"/custom/path/to/claude",
|
"test-coverage-reviewer",
|
||||||
[
|
]);
|
||||||
"plugin",
|
});
|
||||||
"marketplace",
|
|
||||||
"add",
|
test("should handle leading comma", () => {
|
||||||
"https://github.com/user/marketplace.git",
|
expect(parsePlugins(",feature-dev,test-coverage-reviewer")).toEqual([
|
||||||
],
|
"feature-dev",
|
||||||
{ stdio: "inherit" },
|
"test-coverage-reviewer",
|
||||||
);
|
]);
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
});
|
||||||
2,
|
|
||||||
"/custom/path/to/claude",
|
test("should handle plugins with special characters", () => {
|
||||||
["plugin", "install", "test-plugin"],
|
expect(parsePlugins("@scope/plugin-name,plugin-name-2")).toEqual([
|
||||||
{ stdio: "inherit" },
|
"@scope/plugin-name",
|
||||||
);
|
"plugin-name-2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle complex whitespace patterns", () => {
|
||||||
|
expect(
|
||||||
|
parsePlugins(
|
||||||
|
"\n feature-dev \n,\t test-coverage-reviewer\t, code-quality \n",
|
||||||
|
),
|
||||||
|
).toEqual(["feature-dev", "test-coverage-reviewer", "code-quality"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import { parseSdkOptions } from "../src/parse-sdk-options";
|
|
||||||
import type { ClaudeOptions } from "../src/run-claude";
|
|
||||||
|
|
||||||
describe("parseSdkOptions", () => {
|
|
||||||
describe("allowedTools merging", () => {
|
|
||||||
test("should extract allowedTools from claudeArgs", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--allowedTools "Edit,Read,Write"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
|
||||||
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should extract allowedTools from claudeArgs with MCP tools", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs:
|
|
||||||
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual([
|
|
||||||
"Edit",
|
|
||||||
"Read",
|
|
||||||
"mcp__github_comment__update_claude_comment",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accumulate multiple --allowedTools flags from claudeArgs", () => {
|
|
||||||
// This simulates tag mode adding its tools, then user adding their own
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs:
|
|
||||||
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment" --model "claude-3" --allowedTools "Bash(npm install),mcp__github__get_issue"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual([
|
|
||||||
"Edit",
|
|
||||||
"Read",
|
|
||||||
"mcp__github_comment__update_claude_comment",
|
|
||||||
"Bash(npm install)",
|
|
||||||
"mcp__github__get_issue",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge allowedTools from both claudeArgs and direct options", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--allowedTools "Edit,Read"',
|
|
||||||
allowedTools: "Write,Glob",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual([
|
|
||||||
"Edit",
|
|
||||||
"Read",
|
|
||||||
"Write",
|
|
||||||
"Glob",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should deduplicate allowedTools when merging", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--allowedTools "Edit,Read"',
|
|
||||||
allowedTools: "Edit,Write",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should use only direct options when claudeArgs has no allowedTools", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--model "claude-3-5-sonnet"',
|
|
||||||
allowedTools: "Edit,Read",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return undefined allowedTools when neither source has it", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--model "claude-3-5-sonnet"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should remove allowedTools from extraArgs after extraction", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--allowedTools "Edit,Read" --model "claude-3-5-sonnet"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle hyphenated --allowed-tools flag", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--allowed-tools "Edit,Read,Write"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
|
||||||
expect(result.sdkOptions.extraArgs?.["allowed-tools"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accumulate multiple --allowed-tools flags (hyphenated)", () => {
|
|
||||||
// This is the exact scenario from issue #746
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs:
|
|
||||||
'--allowed-tools "Bash(git log:*)" "Bash(git diff:*)" "Bash(git fetch:*)" "Bash(gh pr:*)"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual([
|
|
||||||
"Bash(git log:*)",
|
|
||||||
"Bash(git diff:*)",
|
|
||||||
"Bash(git fetch:*)",
|
|
||||||
"Bash(gh pr:*)",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle mixed camelCase and hyphenated allowedTools flags", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
// Both should be merged - note: order depends on which key is found first
|
|
||||||
expect(result.sdkOptions.allowedTools).toContain("Edit");
|
|
||||||
expect(result.sdkOptions.allowedTools).toContain("Read");
|
|
||||||
expect(result.sdkOptions.allowedTools).toContain("Write");
|
|
||||||
expect(result.sdkOptions.allowedTools).toContain("Glob");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("disallowedTools merging", () => {
|
|
||||||
test("should extract disallowedTools from claudeArgs", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--disallowedTools "Bash,Write"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
|
|
||||||
expect(result.sdkOptions.extraArgs?.["disallowedTools"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge disallowedTools from both sources", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--disallowedTools "Bash"',
|
|
||||||
disallowedTools: "Write",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mcp-config merging", () => {
|
|
||||||
test("should pass through single mcp-config in extraArgs", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}'`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
|
|
||||||
'{"mcpServers":{"server1":{"command":"cmd1"}}}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge multiple mcp-config flags with inline JSON", () => {
|
|
||||||
// Simulates action prepending its config, then user providing their own
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node","args":["server.js"]}}}' --mcp-config '{"mcpServers":{"user_server":{"command":"custom","args":["run"]}}}'`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
const mcpConfig = JSON.parse(
|
|
||||||
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
||||||
);
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("user_server");
|
|
||||||
expect(mcpConfig.mcpServers.github_comment.command).toBe("node");
|
|
||||||
expect(mcpConfig.mcpServers.user_server.command).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge three mcp-config flags", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}' --mcp-config '{"mcpServers":{"server2":{"command":"cmd2"}}}' --mcp-config '{"mcpServers":{"server3":{"command":"cmd3"}}}'`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
const mcpConfig = JSON.parse(
|
|
||||||
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
||||||
);
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server3");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle mcp-config file path when no inline JSON exists", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--mcp-config /tmp/user-mcp-config.json`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
|
|
||||||
"/tmp/user-mcp-config.json",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should merge inline JSON configs when file path is also present", () => {
|
|
||||||
// When action provides inline JSON and user provides a file path,
|
|
||||||
// the inline JSON configs should be merged (file paths cannot be merged at parse time)
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config '{"mcpServers":{"github_ci":{"command":"node"}}}' --mcp-config /tmp/user-config.json`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
// The inline JSON configs should be merged
|
|
||||||
const mcpConfig = JSON.parse(
|
|
||||||
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
||||||
);
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle mcp-config with other flags", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{}}}' --model claude-3-5-sonnet --mcp-config '{"mcpServers":{"server2":{}}}'`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
const mcpConfig = JSON.parse(
|
|
||||||
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
||||||
);
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle real-world scenario: action config + user config", () => {
|
|
||||||
// This is the exact scenario from the bug report
|
|
||||||
const actionConfig = JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
github_comment: {
|
|
||||||
command: "node",
|
|
||||||
args: ["github-comment-server.js"],
|
|
||||||
},
|
|
||||||
github_ci: { command: "node", args: ["github-ci-server.js"] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const userConfig = JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
my_custom_server: { command: "python", args: ["server.py"] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--mcp-config '${actionConfig}' --mcp-config '${userConfig}'`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
const mcpConfig = JSON.parse(
|
|
||||||
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
|
||||||
);
|
|
||||||
// All servers should be present
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
|
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("my_custom_server");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("other extraArgs passthrough", () => {
|
|
||||||
test("should pass through json-schema in extraArgs", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: `--json-schema '{"type":"object"}'`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["json-schema"]).toBe(
|
|
||||||
'{"type":"object"}',
|
|
||||||
);
|
|
||||||
expect(result.hasJsonSchema).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -78,19 +78,5 @@ describe("prepareRunConfig", () => {
|
|||||||
"stream-json",
|
"stream-json",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should include json-schema flag when provided", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs:
|
|
||||||
'--json-schema \'{"type":"object","properties":{"result":{"type":"boolean"}}}\'',
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--json-schema");
|
|
||||||
expect(prepared.claudeArgs).toContain(
|
|
||||||
'{"type":"object","properties":{"result":{"type":"boolean"}}}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
|
||||||
import { writeFile, unlink } from "fs/promises";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
import { join } from "path";
|
|
||||||
import {
|
|
||||||
parseAndSetStructuredOutputs,
|
|
||||||
parseAndSetSessionId,
|
|
||||||
} from "../src/run-claude";
|
|
||||||
import * as core from "@actions/core";
|
|
||||||
|
|
||||||
// Mock execution file path
|
|
||||||
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
|
|
||||||
|
|
||||||
// Helper to create mock execution file with structured output
|
|
||||||
async function createMockExecutionFile(
|
|
||||||
structuredOutput?: Record<string, unknown>,
|
|
||||||
includeResult: boolean = true,
|
|
||||||
): Promise<void> {
|
|
||||||
const messages: any[] = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "turn", content: "test" },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (includeResult) {
|
|
||||||
messages.push({
|
|
||||||
type: "result",
|
|
||||||
cost_usd: 0.01,
|
|
||||||
duration_ms: 1000,
|
|
||||||
structured_output: structuredOutput,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spy on core functions
|
|
||||||
let setOutputSpy: any;
|
|
||||||
let infoSpy: any;
|
|
||||||
let warningSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
|
||||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
|
||||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseAndSetStructuredOutputs", () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
setOutputSpy?.mockRestore();
|
|
||||||
infoSpy?.mockRestore();
|
|
||||||
warningSpy?.mockRestore();
|
|
||||||
try {
|
|
||||||
await unlink(TEST_EXECUTION_FILE);
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should set structured_output with valid data", async () => {
|
|
||||||
await createMockExecutionFile({
|
|
||||||
is_flaky: true,
|
|
||||||
confidence: 0.85,
|
|
||||||
summary: "Test looks flaky",
|
|
||||||
});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith(
|
|
||||||
"structured_output",
|
|
||||||
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
|
|
||||||
);
|
|
||||||
expect(infoSpy).toHaveBeenCalledWith(
|
|
||||||
"Set structured_output with 3 field(s)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle arrays and nested objects", async () => {
|
|
||||||
await createMockExecutionFile({
|
|
||||||
items: ["a", "b", "c"],
|
|
||||||
config: { key: "value", nested: { deep: true } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
const callArgs = setOutputSpy.mock.calls[0];
|
|
||||||
expect(callArgs[0]).toBe("structured_output");
|
|
||||||
const parsed = JSON.parse(callArgs[1]);
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
items: ["a", "b", "c"],
|
|
||||||
config: { key: "value", nested: { deep: true } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle special characters in field names", async () => {
|
|
||||||
await createMockExecutionFile({
|
|
||||||
"test-result": "passed",
|
|
||||||
"item.count": 10,
|
|
||||||
"user@email": "test",
|
|
||||||
});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
const callArgs = setOutputSpy.mock.calls[0];
|
|
||||||
const parsed = JSON.parse(callArgs[1]);
|
|
||||||
expect(parsed["test-result"]).toBe("passed");
|
|
||||||
expect(parsed["item.count"]).toBe(10);
|
|
||||||
expect(parsed["user@email"]).toBe("test");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when result exists but structured_output is undefined", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"--json-schema was provided but Claude did not return structured_output",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when no result message exists", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "turn", content: "test" },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"--json-schema was provided but Claude did not return structured_output",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error with malformed JSON", async () => {
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when file does not exist", async () => {
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs("/nonexistent/file.json"),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle empty structured_output object", async () => {
|
|
||||||
await createMockExecutionFile({});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
|
|
||||||
expect(infoSpy).toHaveBeenCalledWith(
|
|
||||||
"Set structured_output with 0 field(s)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseAndSetSessionId", () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
setOutputSpy?.mockRestore();
|
|
||||||
infoSpy?.mockRestore();
|
|
||||||
warningSpy?.mockRestore();
|
|
||||||
try {
|
|
||||||
await unlink(TEST_EXECUTION_FILE);
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should extract session_id from system.init message", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init", session_id: "test-session-123" },
|
|
||||||
{ type: "result", cost_usd: 0.01 },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
|
|
||||||
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle missing session_id gracefully", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "result", cost_usd: 0.01 },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle missing system.init message gracefully", async () => {
|
|
||||||
const messages = [{ type: "result", cost_usd: 0.01 }];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle malformed JSON gracefully with warning", async () => {
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle non-existent file gracefully with warning", async () => {
|
|
||||||
await parseAndSetSessionId("/nonexistent/file.json");
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,19 +13,15 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
|
||||||
delete process.env.AWS_REGION;
|
delete process.env.AWS_REGION;
|
||||||
delete process.env.AWS_ACCESS_KEY_ID;
|
delete process.env.AWS_ACCESS_KEY_ID;
|
||||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||||
delete process.env.AWS_SESSION_TOKEN;
|
delete process.env.AWS_SESSION_TOKEN;
|
||||||
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
|
|
||||||
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
||||||
delete process.env.ANTHROPIC_VERTEX_PROJECT_ID;
|
delete process.env.ANTHROPIC_VERTEX_PROJECT_ID;
|
||||||
delete process.env.CLOUD_ML_REGION;
|
delete process.env.CLOUD_ML_REGION;
|
||||||
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||||
delete process.env.ANTHROPIC_VERTEX_BASE_URL;
|
delete process.env.ANTHROPIC_VERTEX_BASE_URL;
|
||||||
delete process.env.ANTHROPIC_FOUNDRY_RESOURCE;
|
|
||||||
delete process.env.ANTHROPIC_FOUNDRY_BASE_URL;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,58 +92,31 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should fail when only AWS_SECRET_ACCESS_KEY is provided without bearer token", () => {
|
test("should fail when AWS_ACCESS_KEY_ID is missing", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
process.env.AWS_REGION = "us-east-1";
|
process.env.AWS_REGION = "us-east-1";
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
"AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should fail when only AWS_ACCESS_KEY_ID is provided without bearer token", () => {
|
test("should fail when AWS_SECRET_ACCESS_KEY is missing", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
process.env.AWS_REGION = "us-east-1";
|
process.env.AWS_REGION = "us-east-1";
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
"AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should pass when AWS_BEARER_TOKEN_BEDROCK is provided instead of access keys", () => {
|
test("should report all missing Bedrock variables", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_BEARER_TOKEN_BEDROCK = "test-bearer-token";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass when both bearer token and access keys are provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_BEARER_TOKEN_BEDROCK = "test-bearer-token";
|
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when no authentication method is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should report missing region and authentication", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
/AWS_REGION is required when using AWS Bedrock.*Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock/s,
|
/AWS_REGION is required when using AWS Bedrock.*AWS_ACCESS_KEY_ID is required when using AWS Bedrock.*AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock/s,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -198,56 +167,6 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Microsoft Foundry", () => {
|
|
||||||
test("should pass when ANTHROPIC_FOUNDRY_RESOURCE is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass when ANTHROPIC_FOUNDRY_BASE_URL is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_BASE_URL =
|
|
||||||
"https://test-resource.services.ai.azure.com";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass when both resource and base URL are provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_BASE_URL =
|
|
||||||
"https://custom.services.ai.azure.com";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should construct Foundry base URL from resource name when ANTHROPIC_FOUNDRY_BASE_URL is not provided", () => {
|
|
||||||
// This test verifies our action.yml change, which constructs:
|
|
||||||
// ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL || (env.ANTHROPIC_FOUNDRY_RESOURCE && format('https://{0}.services.ai.azure.com', env.ANTHROPIC_FOUNDRY_RESOURCE)) }}
|
|
||||||
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "my-foundry-resource";
|
|
||||||
// ANTHROPIC_FOUNDRY_BASE_URL is intentionally not set
|
|
||||||
|
|
||||||
// The actual URL construction happens in the composite action in action.yml
|
|
||||||
// This test is a placeholder to document the behavior
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
|
|
||||||
// In the actual action, ANTHROPIC_FOUNDRY_BASE_URL would be:
|
|
||||||
// https://my-foundry-resource.services.ai.azure.com
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when neither ANTHROPIC_FOUNDRY_RESOURCE nor ANTHROPIC_FOUNDRY_BASE_URL is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Either ANTHROPIC_FOUNDRY_RESOURCE or ANTHROPIC_FOUNDRY_BASE_URL is required when using Microsoft Foundry.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Multiple providers", () => {
|
describe("Multiple providers", () => {
|
||||||
test("should fail when both Bedrock and Vertex are enabled", () => {
|
test("should fail when both Bedrock and Vertex are enabled", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
@@ -260,51 +179,7 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
process.env.CLOUD_ML_REGION = "us-central1";
|
process.env.CLOUD_ML_REGION = "us-central1";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when both Bedrock and Foundry are enabled", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
// Provide all required vars to isolate the mutual exclusion error
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when both Vertex and Foundry are enabled", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
// Provide all required vars to isolate the mutual exclusion error
|
|
||||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
|
||||||
process.env.CLOUD_ML_REGION = "us-central1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when all three providers are enabled", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
// Provide all required vars to isolate the mutual exclusion error
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
||||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
|
||||||
process.env.CLOUD_ML_REGION = "us-central1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -329,7 +204,10 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
" - AWS_REGION is required when using AWS Bedrock.",
|
" - AWS_REGION is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
expect(error!.message).toContain(
|
expect(error!.message).toContain(
|
||||||
" - Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
" - AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||||
|
);
|
||||||
|
expect(error!.message).toContain(
|
||||||
|
" - AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
34
bun.lock
34
bun.lock
@@ -1,13 +1,11 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@anthropic-ai/claude-code-action",
|
"name": "@anthropic-ai/claude-code-action",
|
||||||
"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.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,40 +35,8 @@
|
|||||||
|
|
||||||
"@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.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=="],
|
||||||
|
|
||||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
|
||||||
|
|
||||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
|
||||||
|
|
||||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
|
||||||
|
|
||||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
|
||||||
|
|
||||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
|
||||||
|
|
||||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.16.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.16.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg=="],
|
||||||
|
|
||||||
"@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="],
|
"@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="],
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
# Cloud Providers
|
# Cloud Providers
|
||||||
|
|
||||||
You can authenticate with Claude using any of these four methods:
|
You can authenticate with Claude using any of these three methods:
|
||||||
|
|
||||||
1. Direct Anthropic API (default)
|
1. Direct Anthropic API (default)
|
||||||
2. Amazon Bedrock with OIDC authentication
|
2. Amazon Bedrock with OIDC authentication
|
||||||
3. Google Vertex AI with OIDC authentication
|
3. Google Vertex AI with OIDC authentication
|
||||||
4. Microsoft Foundry with OIDC authentication
|
|
||||||
|
|
||||||
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions#using-with-aws-bedrock-%26-google-vertex-ai).
|
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions#using-with-aws-bedrock-%26-google-vertex-ai).
|
||||||
|
|
||||||
**Note**:
|
**Note**:
|
||||||
|
|
||||||
- Bedrock, Vertex, and Microsoft Foundry use OIDC authentication exclusively
|
- Bedrock and Vertex use OIDC authentication exclusively
|
||||||
- AWS Bedrock automatically uses cross-region inference profiles for certain models
|
- AWS Bedrock automatically uses cross-region inference profiles for certain models
|
||||||
- For cross-region inference profile models, you need to request and be granted access to the Claude models in all regions that the inference profile uses
|
- For cross-region inference profile models, you need to request and be granted access to the Claude models in all regions that the inference profile uses
|
||||||
|
|
||||||
@@ -41,19 +40,11 @@ Use provider-specific model names based on your chosen provider:
|
|||||||
claude_args: |
|
claude_args: |
|
||||||
--model claude-4-0-sonnet@20250805
|
--model claude-4-0-sonnet@20250805
|
||||||
# ... other inputs
|
# ... other inputs
|
||||||
|
|
||||||
# For Microsoft Foundry with OIDC
|
|
||||||
- uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
use_foundry: "true"
|
|
||||||
claude_args: |
|
|
||||||
--model claude-sonnet-4-5
|
|
||||||
# ... other inputs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## OIDC Authentication for Cloud Providers
|
## OIDC Authentication for Bedrock and Vertex
|
||||||
|
|
||||||
AWS Bedrock, GCP Vertex AI, and Microsoft Foundry all support OIDC authentication.
|
Both AWS Bedrock and GCP Vertex AI require OIDC authentication.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# For AWS Bedrock with OIDC
|
# For AWS Bedrock with OIDC
|
||||||
@@ -106,36 +97,3 @@ AWS Bedrock, GCP Vertex AI, and Microsoft Foundry all support OIDC authenticatio
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # Required for OIDC
|
id-token: write # Required for OIDC
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
|
||||||
# For Microsoft Foundry with OIDC
|
|
||||||
- name: Authenticate to Azure
|
|
||||||
uses: azure/login@v2
|
|
||||||
with:
|
|
||||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
|
|
||||||
- name: Generate GitHub App token
|
|
||||||
id: app-token
|
|
||||||
uses: actions/create-github-app-token@v2
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
use_foundry: "true"
|
|
||||||
claude_args: |
|
|
||||||
--model claude-sonnet-4-5
|
|
||||||
# ... other inputs
|
|
||||||
env:
|
|
||||||
ANTHROPIC_FOUNDRY_BASE_URL: https://my-resource.services.ai.azure.com
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write # Required for OIDC
|
|
||||||
```
|
|
||||||
|
|
||||||
## Microsoft Foundry Setup
|
|
||||||
|
|
||||||
For detailed setup instructions for Microsoft Foundry, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/microsoft-foundry).
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ To allow Claude to view workflow run results, job logs, and CI status:
|
|||||||
2. **Configure the action with additional permissions**:
|
2. **Configure the action with additional permissions**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: anthropics/claude-code-action@v1
|
- uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
@@ -162,7 +162,7 @@ jobs:
|
|||||||
claude-ci-helper:
|
claude-ci-helper:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: anthropics/claude-code-action@v1
|
- uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
|
|||||||
@@ -1,744 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Create Claude Code GitHub App</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Claude Brand Colors */
|
|
||||||
--primary-dark: #0e0e0e;
|
|
||||||
--primary-light: #d4a27f;
|
|
||||||
--background-light: rgb(253, 253, 247);
|
|
||||||
--background-dark: rgb(9, 9, 11);
|
|
||||||
--text-primary: #1a1a1a;
|
|
||||||
--text-secondary: #525252;
|
|
||||||
--text-tertiary: #737373;
|
|
||||||
--border-color: rgba(0, 0, 0, 0.08);
|
|
||||||
--hover-bg: rgba(0, 0, 0, 0.02);
|
|
||||||
--success: #2ea44f;
|
|
||||||
--warning: #e3b341;
|
|
||||||
--card-shadow:
|
|
||||||
0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
--card-shadow-hover:
|
|
||||||
0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
|
||||||
background: var(--background-light);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.6;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 40px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
.card {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 32px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
box-shadow: var(--card-shadow);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: var(--card-shadow-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-description {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: inherit;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #1a1a1a;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--primary-light);
|
|
||||||
color: var(--primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #c99a70;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(212, 162, 127, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: white;
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
border-color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.copied {
|
|
||||||
background: var(--success);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form */
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 15px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary-dark);
|
|
||||||
box-shadow: 0 0 0 3px rgba(14, 14, 14, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code Block */
|
|
||||||
.code-container {
|
|
||||||
position: relative;
|
|
||||||
background: #fafafa;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
background: white;
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn:hover {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn.copied {
|
|
||||||
background: var(--success);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-block {
|
|
||||||
padding: 16px;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-family:
|
|
||||||
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
|
||||||
"Courier New", monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Permissions List */
|
|
||||||
.permissions-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-icon {
|
|
||||||
color: var(--success);
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-value {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Steps */
|
|
||||||
.steps {
|
|
||||||
margin: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-number {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
background: var(--primary-dark);
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-content {
|
|
||||||
flex: 1;
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-content p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-content strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alert Box */
|
|
||||||
.alert {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
background: #fffbf0;
|
|
||||||
border: 1px solid #f5e7c3;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-icon {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-content {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-content strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.button-group {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.permissions-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 24px 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hidden form elements */
|
|
||||||
.hidden-form {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<header>
|
|
||||||
<h1>Create Your Custom GitHub App</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
Set up a custom GitHub App for Claude Code Action with all required
|
|
||||||
permissions automatically configured.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Quick Setup Card -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-icon">🚀</span>
|
|
||||||
<h2>Quick Setup</h2>
|
|
||||||
</div>
|
|
||||||
<p class="card-description">
|
|
||||||
Create your GitHub App with one click. All permissions will be
|
|
||||||
automatically configured for Claude Code Action.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="button-group">
|
|
||||||
<!-- Personal Account Button -->
|
|
||||||
<form
|
|
||||||
action="https://github.com/settings/apps/new"
|
|
||||||
method="post"
|
|
||||||
class="hidden-form"
|
|
||||||
id="personal-form"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="manifest" id="personal-manifest" />
|
|
||||||
</form>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
onclick="submitPersonalForm()"
|
|
||||||
>
|
|
||||||
<span>👤</span>
|
|
||||||
<span>Create for Personal Account</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Organization Form -->
|
|
||||||
<form id="org-form" method="post" class="hidden-form">
|
|
||||||
<input type="hidden" name="manifest" id="org-manifest" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Organization Input -->
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
margin-top: 24px;
|
|
||||||
padding-top: 24px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<label for="org-name" style="margin-bottom: 8px"
|
|
||||||
>Or create for an organization:</label
|
|
||||||
>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="org-name"
|
|
||||||
placeholder="Enter organization name (e.g., my-org)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
onclick="submitOrgForm()"
|
|
||||||
style="flex-shrink: 0"
|
|
||||||
>
|
|
||||||
<span>🏢</span>
|
|
||||||
<span>Create for Org</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Permissions Card -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-icon">✅</span>
|
|
||||||
<h2>Configured Permissions</h2>
|
|
||||||
</div>
|
|
||||||
<p class="card-description">
|
|
||||||
Your GitHub App will be created with these permissions:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="permissions-grid">
|
|
||||||
<div class="permission-item">
|
|
||||||
<span class="permission-icon">✓</span>
|
|
||||||
<span class="permission-name">Contents</span>
|
|
||||||
<span class="permission-value">Read & Write</span>
|
|
||||||
</div>
|
|
||||||
<div class="permission-item">
|
|
||||||
<span class="permission-icon">✓</span>
|
|
||||||
<span class="permission-name">Issues</span>
|
|
||||||
<span class="permission-value">Read & Write</span>
|
|
||||||
</div>
|
|
||||||
<div class="permission-item">
|
|
||||||
<span class="permission-icon">✓</span>
|
|
||||||
<span class="permission-name">Pull Requests</span>
|
|
||||||
<span class="permission-value">Read & Write</span>
|
|
||||||
</div>
|
|
||||||
<div class="permission-item">
|
|
||||||
<span class="permission-icon">✓</span>
|
|
||||||
<span class="permission-name">Actions</span>
|
|
||||||
<span class="permission-value">Read</span>
|
|
||||||
</div>
|
|
||||||
<div class="permission-item">
|
|
||||||
<span class="permission-icon">✓</span>
|
|
||||||
<span class="permission-name">Metadata</span>
|
|
||||||
<span class="permission-value">Read</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Next Steps Card -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-icon">📋</span>
|
|
||||||
<h2>Next Steps</h2>
|
|
||||||
</div>
|
|
||||||
<p class="card-description">
|
|
||||||
After creating your app, complete these steps:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">1</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<p>
|
|
||||||
<strong>Generate a private key:</strong> In your app settings,
|
|
||||||
scroll to "Private keys" and click "Generate a private key"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">2</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<p>
|
|
||||||
<strong>Install the app:</strong> Click "Install App" and select
|
|
||||||
the repositories where you want to use Claude
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">3</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<p>
|
|
||||||
<strong>Configure your workflow:</strong> Add your app's ID and
|
|
||||||
private key to your repository secrets
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual Setup Card -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-icon">⚙️</span>
|
|
||||||
<h2>Manual Setup</h2>
|
|
||||||
</div>
|
|
||||||
<p class="card-description">
|
|
||||||
If the buttons above don't work, you can manually create the app by
|
|
||||||
copying the manifest JSON below:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="code-container">
|
|
||||||
<div class="code-header">
|
|
||||||
<span class="code-label">github-app-manifest.json</span>
|
|
||||||
<button class="copy-btn" onclick="copyManifest()">Copy</button>
|
|
||||||
</div>
|
|
||||||
<div class="code-block" id="manifest-json"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">1</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<p>Copy the manifest JSON above</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">2</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<p>
|
|
||||||
Go to
|
|
||||||
<a
|
|
||||||
href="https://github.com/settings/apps/new"
|
|
||||||
target="_blank"
|
|
||||||
style="color: var(--primary-dark); text-decoration: underline"
|
|
||||||
>GitHub App Settings</a
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<div class="step-number">3</div>
|
|
||||||
<div class="step-content">
|
|
||||||
<p>Look for "Create from manifest" option and paste the JSON</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Warning Alert -->
|
|
||||||
<div class="alert">
|
|
||||||
<span class="alert-icon">⚠️</span>
|
|
||||||
<div class="alert-content">
|
|
||||||
<strong>Important:</strong> Keep your private key secure! Never commit
|
|
||||||
it to your repository. Always use GitHub secrets to store sensitive
|
|
||||||
credentials.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Manifest configuration
|
|
||||||
const manifest = {
|
|
||||||
name: "Claude Code Custom App",
|
|
||||||
description:
|
|
||||||
"Custom GitHub App for Claude Code Action - AI-powered coding assistant for GitHub workflows",
|
|
||||||
url: "https://github.com/anthropics/claude-code-action",
|
|
||||||
hook_attributes: {
|
|
||||||
url: "https://example.com/github/webhook",
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
redirect_url: "https://github.com/settings/apps/new",
|
|
||||||
callback_urls: [],
|
|
||||||
setup_url:
|
|
||||||
"https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md",
|
|
||||||
public: false,
|
|
||||||
default_permissions: {
|
|
||||||
contents: "write",
|
|
||||||
issues: "write",
|
|
||||||
pull_requests: "write",
|
|
||||||
actions: "read",
|
|
||||||
metadata: "read",
|
|
||||||
},
|
|
||||||
default_events: [
|
|
||||||
"issue_comment",
|
|
||||||
"issues",
|
|
||||||
"pull_request",
|
|
||||||
"pull_request_review",
|
|
||||||
"pull_request_review_comment",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Populate manifest fields
|
|
||||||
const manifestJson = JSON.stringify(manifest);
|
|
||||||
const manifestJsonPretty = JSON.stringify(manifest, null, 2);
|
|
||||||
|
|
||||||
document.getElementById("personal-manifest").value = manifestJson;
|
|
||||||
document.getElementById("org-manifest").value = manifestJson;
|
|
||||||
|
|
||||||
// Display formatted JSON
|
|
||||||
const manifestDisplay = document.getElementById("manifest-json");
|
|
||||||
manifestDisplay.textContent = manifestJsonPretty;
|
|
||||||
|
|
||||||
// Submit personal form
|
|
||||||
function submitPersonalForm() {
|
|
||||||
document.getElementById("personal-form").submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit organization form
|
|
||||||
function submitOrgForm() {
|
|
||||||
const orgName = document.getElementById("org-name").value.trim();
|
|
||||||
if (!orgName) {
|
|
||||||
alert("Please enter an organization name");
|
|
||||||
document.getElementById("org-name").focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const form = document.getElementById("org-form");
|
|
||||||
form.action = `https://github.com/organizations/${orgName}/settings/apps/new`;
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow Enter key to submit org form
|
|
||||||
document
|
|
||||||
.getElementById("org-name")
|
|
||||||
.addEventListener("keypress", function (e) {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
submitOrgForm();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy manifest to clipboard
|
|
||||||
function copyManifest() {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(manifestJsonPretty)
|
|
||||||
.then(() => {
|
|
||||||
const button = document.querySelector(".copy-btn");
|
|
||||||
const originalText = button.textContent;
|
|
||||||
button.textContent = "Copied!";
|
|
||||||
button.classList.add("copied");
|
|
||||||
setTimeout(() => {
|
|
||||||
button.textContent = originalText;
|
|
||||||
button.classList.remove("copied");
|
|
||||||
}, 2000);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Fallback for older browsers
|
|
||||||
const textArea = document.createElement("textarea");
|
|
||||||
textArea.value = manifestJsonPretty;
|
|
||||||
textArea.style.position = "fixed";
|
|
||||||
textArea.style.opacity = "0";
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
try {
|
|
||||||
document.execCommand("copy");
|
|
||||||
const button = document.querySelector(".copy-btn");
|
|
||||||
const originalText = button.textContent;
|
|
||||||
button.textContent = "Copied!";
|
|
||||||
button.classList.add("copied");
|
|
||||||
setTimeout(() => {
|
|
||||||
button.textContent = originalText;
|
|
||||||
button.classList.remove("copied");
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
alert("Failed to copy. Please copy manually.");
|
|
||||||
}
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -61,3 +61,68 @@ For specialized use cases, you can fine-tune behavior using `claude_args`:
|
|||||||
--system-prompt "You are a code review specialist"
|
--system-prompt "You are a code review specialist"
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Network Restrictions
|
||||||
|
|
||||||
|
For enhanced security, you can restrict Claude's network access to specific domains only. This feature is particularly useful for:
|
||||||
|
|
||||||
|
- Enterprise environments with strict security policies
|
||||||
|
- Preventing access to external services
|
||||||
|
- Limiting Claude to only your internal APIs and services
|
||||||
|
|
||||||
|
When `experimental_allowed_domains` is set, Claude can only access the domains you explicitly list. You'll need to include the appropriate provider domains based on your authentication method.
|
||||||
|
|
||||||
|
### Provider-Specific Examples
|
||||||
|
|
||||||
|
#### If using Anthropic API or subscription
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# Or: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
.anthropic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If using AWS Bedrock
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
use_bedrock: "true"
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
bedrock.*.amazonaws.com
|
||||||
|
bedrock-runtime.*.amazonaws.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If using Google Vertex AI
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
use_vertex: "true"
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
*.googleapis.com
|
||||||
|
vertexai.googleapis.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common GitHub Domains
|
||||||
|
|
||||||
|
In addition to your provider domains, you may need to include GitHub-related domains. For GitHub.com users, common domains include:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
.anthropic.com # For Anthropic API
|
||||||
|
.github.com
|
||||||
|
.githubusercontent.com
|
||||||
|
ghcr.io
|
||||||
|
.blob.core.windows.net
|
||||||
|
```
|
||||||
|
|
||||||
|
For GitHub Enterprise users, replace the GitHub.com domains above with your enterprise domains (e.g., `.github.company.com`, `packages.company.com`, etc.).
|
||||||
|
|
||||||
|
To determine which domains your workflow needs, you can temporarily run without restrictions and monitor the network requests, or check your GitHub Enterprise configuration for the specific services you use.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ The following permissions are requested but not yet actively used. These will en
|
|||||||
|
|
||||||
## Commit Signing
|
## Commit Signing
|
||||||
|
|
||||||
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.
|
All commits made by Claude through this action are automatically signed with commit signatures. This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
||||||
|
|
||||||
## ⚠️ Authentication Protection
|
## ⚠️ Authentication Protection
|
||||||
|
|
||||||
@@ -56,31 +56,3 @@ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|||||||
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
||||||
claude_code_oauth_token: "oauth_token_..." # Exposed and vulnerable!
|
claude_code_oauth_token: "oauth_token_..." # Exposed and vulnerable!
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Full Output Security Warning
|
|
||||||
|
|
||||||
The `show_full_output` option is **disabled by default** for security reasons. When enabled, it outputs ALL Claude Code messages including:
|
|
||||||
|
|
||||||
- Full outputs from tool executions (e.g., `ps`, `env`, file reads)
|
|
||||||
- API responses that may contain tokens or credentials
|
|
||||||
- File contents that may include secrets
|
|
||||||
- Command outputs that may expose sensitive system information
|
|
||||||
|
|
||||||
**These logs are publicly visible in GitHub Actions for public repositories!**
|
|
||||||
|
|
||||||
### Automatic Enabling in Debug Mode
|
|
||||||
|
|
||||||
Full output is **automatically enabled** when GitHub Actions debug mode is active (when `ACTIONS_STEP_DEBUG` secret is set to `true`). This helps with debugging but carries the same security risks.
|
|
||||||
|
|
||||||
### When to Enable Full Output
|
|
||||||
|
|
||||||
Only enable `show_full_output: true` or GitHub Actions debug mode when:
|
|
||||||
|
|
||||||
- Working in a private repository with controlled access
|
|
||||||
- Debugging issues in a non-production environment
|
|
||||||
- You have verified no secrets will be exposed in the output
|
|
||||||
- You understand the security implications
|
|
||||||
|
|
||||||
### Recommended Practice
|
|
||||||
|
|
||||||
For debugging, prefer using `show_full_output: false` (the default) and rely on Claude Code's sanitized output, which shows only essential information like errors and completion status without exposing sensitive data.
|
|
||||||
|
|||||||
@@ -20,48 +20,7 @@ If you prefer not to install the official Claude app, you can create your own Gi
|
|||||||
- Organization policies prevent installing third-party apps
|
- Organization policies prevent installing third-party apps
|
||||||
- You're using AWS Bedrock or Google Vertex AI
|
- You're using AWS Bedrock or Google Vertex AI
|
||||||
|
|
||||||
### Option 1: Quick Setup with App Manifest (Recommended)
|
**Steps to create and use a custom GitHub App:**
|
||||||
|
|
||||||
The fastest way to create a custom GitHub App is using our pre-configured manifest. This ensures all permissions are correctly set up with a single click.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
|
|
||||||
1. **Create the app:**
|
|
||||||
|
|
||||||
**🚀 [Download the Quick Setup Tool](./create-app.html)** (Right-click → "Save Link As" or "Download Linked File")
|
|
||||||
|
|
||||||
After downloading, open `create-app.html` in your web browser:
|
|
||||||
|
|
||||||
- **For Personal Accounts:** Click the "Create App for Personal Account" button
|
|
||||||
- **For Organizations:** Enter your organization name and click "Create App for Organization"
|
|
||||||
|
|
||||||
The tool will automatically configure all required permissions and submit the manifest.
|
|
||||||
|
|
||||||
Alternatively, you can use the manifest file directly:
|
|
||||||
|
|
||||||
- Use the [`github-app-manifest.json`](../github-app-manifest.json) file from this repository
|
|
||||||
- Visit https://github.com/settings/apps/new (for personal) or your organization's app settings
|
|
||||||
- Look for the "Create from manifest" option and paste the JSON content
|
|
||||||
|
|
||||||
2. **Complete the creation flow:**
|
|
||||||
|
|
||||||
- GitHub will show you a preview of the app configuration
|
|
||||||
- Confirm the app name (you can customize it)
|
|
||||||
- Click "Create GitHub App"
|
|
||||||
- The app will be created with all required permissions automatically configured
|
|
||||||
|
|
||||||
3. **Generate and download a private key:**
|
|
||||||
|
|
||||||
- After creating the app, you'll be redirected to the app settings
|
|
||||||
- Scroll down to "Private keys"
|
|
||||||
- Click "Generate a private key"
|
|
||||||
- Download the `.pem` file (keep this secure!)
|
|
||||||
|
|
||||||
4. **Continue with installation** - Skip to step 3 in the manual setup below to install the app and configure your workflow.
|
|
||||||
|
|
||||||
### Option 2: Manual Setup
|
|
||||||
|
|
||||||
If you prefer to configure the app manually or need custom permissions:
|
|
||||||
|
|
||||||
1. **Create a new GitHub App:**
|
1. **Create a new GitHub App:**
|
||||||
|
|
||||||
@@ -117,7 +76,7 @@ If you prefer to configure the app manually or need custom permissions:
|
|||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
# Use Claude with your custom app's token
|
# Use Claude with your custom app's token
|
||||||
- uses: anthropics/claude-code-action@v1
|
- uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
github_token: ${{ steps.app-token.outputs.token }}
|
github_token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|||||||
126
docs/usage.md
126
docs/usage.md
@@ -32,11 +32,6 @@ jobs:
|
|||||||
# --max-turns 10
|
# --max-turns 10
|
||||||
# --model claude-4-0-sonnet-20250805
|
# --model claude-4-0-sonnet-20250805
|
||||||
|
|
||||||
# Optional: add custom plugin marketplaces
|
|
||||||
# plugin_marketplaces: "https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git"
|
|
||||||
# Optional: install Claude Code plugins
|
|
||||||
# plugins: "code-review@claude-code-plugins\nfeature-dev@claude-code-plugins"
|
|
||||||
|
|
||||||
# Optional: add custom trigger phrase (default: @claude)
|
# Optional: add custom trigger phrase (default: @claude)
|
||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
# Optional: add assignee trigger for issues
|
# Optional: add assignee trigger for issues
|
||||||
@@ -52,33 +47,32 @@ jobs:
|
|||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
| Input | Description | Required | Default |
|
| Input | Description | Required | Default |
|
||||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- |
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------- |
|
||||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||||
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
||||||
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
||||||
| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
| `claude_args` | Additional arguments to pass directly to Claude CLI (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
||||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
||||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
||||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
||||||
| `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_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | 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 | "" |
|
| `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_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun 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 | "" |
|
||||||
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
|
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||||
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
|
|
||||||
|
|
||||||
### Deprecated Inputs
|
### Deprecated Inputs
|
||||||
|
|
||||||
@@ -184,74 +178,6 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step
|
|||||||
Focus on the changed files in this PR.
|
Focus on the changed files in this PR.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structured Outputs
|
|
||||||
|
|
||||||
Get validated JSON results from Claude that automatically become GitHub Action outputs. This enables building complex automation workflows where Claude analyzes data and subsequent steps use the results.
|
|
||||||
|
|
||||||
### Basic Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Detect flaky tests
|
|
||||||
id: analyze
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
prompt: |
|
|
||||||
Check the CI logs and determine if this is a flaky test.
|
|
||||||
Return: is_flaky (boolean), confidence (0-1), summary (string)
|
|
||||||
claude_args: |
|
|
||||||
--json-schema '{"type":"object","properties":{"is_flaky":{"type":"boolean"},"confidence":{"type":"number"},"summary":{"type":"string"}},"required":["is_flaky"]}'
|
|
||||||
|
|
||||||
- name: Retry if flaky
|
|
||||||
if: fromJSON(steps.analyze.outputs.structured_output).is_flaky == true
|
|
||||||
run: gh workflow run CI
|
|
||||||
```
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. **Define Schema**: Provide a JSON schema via `--json-schema` flag in `claude_args`
|
|
||||||
2. **Claude Executes**: Claude uses tools to complete your task
|
|
||||||
3. **Validated Output**: Result is validated against your schema
|
|
||||||
4. **JSON Output**: All fields are returned in a single `structured_output` JSON string
|
|
||||||
|
|
||||||
### Accessing Structured Outputs
|
|
||||||
|
|
||||||
All structured output fields are available in the `structured_output` output as a JSON string:
|
|
||||||
|
|
||||||
**In GitHub Actions expressions:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
if: fromJSON(steps.analyze.outputs.structured_output).is_flaky == true
|
|
||||||
run: |
|
|
||||||
CONFIDENCE=${{ fromJSON(steps.analyze.outputs.structured_output).confidence }}
|
|
||||||
```
|
|
||||||
|
|
||||||
**In bash with jq:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Process results
|
|
||||||
run: |
|
|
||||||
OUTPUT='${{ steps.analyze.outputs.structured_output }}'
|
|
||||||
IS_FLAKY=$(echo "$OUTPUT" | jq -r '.is_flaky')
|
|
||||||
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Due to GitHub Actions limitations, composite actions cannot expose dynamic outputs. All fields are bundled in the single `structured_output` JSON string.
|
|
||||||
|
|
||||||
### Complete Example
|
|
||||||
|
|
||||||
See `examples/test-failure-analysis.yml` for a working example that:
|
|
||||||
|
|
||||||
- Detects flaky test failures
|
|
||||||
- Uses confidence thresholds in conditionals
|
|
||||||
- Auto-retries workflows
|
|
||||||
- Comments on PRs
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
For complete details on JSON Schema syntax and Agent SDK structured outputs:
|
|
||||||
https://docs.claude.com/en/docs/agent-sdk/structured-outputs
|
|
||||||
|
|
||||||
## Ways to Tag @claude
|
## Ways to Tag @claude
|
||||||
|
|
||||||
These examples show how to interact with Claude using comments in PRs and issues. By default, Claude will be triggered anytime you mention `@claude`, but you can customize the exact trigger phrase using the `trigger_phrase` input in the workflow.
|
These examples show how to interact with Claude using comments in PRs and issues. By default, Claude will be triggered anytime you mention `@claude`, but you can customize the exact trigger phrase using the `trigger_phrase` input in the workflow.
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
name: Auto-Retry Flaky Tests
|
|
||||||
|
|
||||||
# This example demonstrates using structured outputs to detect flaky test failures
|
|
||||||
# and automatically retry them, reducing noise from intermittent failures.
|
|
||||||
#
|
|
||||||
# Use case: When CI fails, automatically determine if it's likely flaky and retry if so.
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI"]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect-flaky:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Detect flaky test failures
|
|
||||||
id: detect
|
|
||||||
uses: anthropics/claude-code-action@main
|
|
||||||
with:
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
prompt: |
|
|
||||||
The CI workflow failed: ${{ github.event.workflow_run.html_url }}
|
|
||||||
|
|
||||||
Check the logs: gh run view ${{ github.event.workflow_run.id }} --log-failed
|
|
||||||
|
|
||||||
Determine if this looks like a flaky test failure by checking for:
|
|
||||||
- Timeout errors
|
|
||||||
- Race conditions
|
|
||||||
- Network errors
|
|
||||||
- "Expected X but got Y" intermittent failures
|
|
||||||
- Tests that passed in previous commits
|
|
||||||
|
|
||||||
Return:
|
|
||||||
- is_flaky: true if likely flaky, false if real bug
|
|
||||||
- confidence: number 0-1 indicating confidence level
|
|
||||||
- summary: brief one-sentence explanation
|
|
||||||
claude_args: |
|
|
||||||
--json-schema '{"type":"object","properties":{"is_flaky":{"type":"boolean","description":"Whether this appears to be a flaky test failure"},"confidence":{"type":"number","minimum":0,"maximum":1,"description":"Confidence level in the determination"},"summary":{"type":"string","description":"One-sentence explanation of the failure"}},"required":["is_flaky","confidence","summary"]}'
|
|
||||||
|
|
||||||
# Auto-retry only if flaky AND high confidence (>= 0.7)
|
|
||||||
- name: Retry flaky tests
|
|
||||||
if: |
|
|
||||||
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
|
|
||||||
fromJSON(steps.detect.outputs.structured_output).confidence >= 0.7
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
OUTPUT='${{ steps.detect.outputs.structured_output }}'
|
|
||||||
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
|
|
||||||
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
|
|
||||||
|
|
||||||
echo "🔄 Flaky test detected (confidence: $CONFIDENCE)"
|
|
||||||
echo "Summary: $SUMMARY"
|
|
||||||
echo ""
|
|
||||||
echo "Triggering automatic retry..."
|
|
||||||
|
|
||||||
gh workflow run "${{ github.event.workflow_run.name }}" \
|
|
||||||
--ref "${{ github.event.workflow_run.head_branch }}"
|
|
||||||
|
|
||||||
# Low confidence flaky detection - skip retry
|
|
||||||
- name: Low confidence detection
|
|
||||||
if: |
|
|
||||||
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
|
|
||||||
fromJSON(steps.detect.outputs.structured_output).confidence < 0.7
|
|
||||||
run: |
|
|
||||||
OUTPUT='${{ steps.detect.outputs.structured_output }}'
|
|
||||||
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
|
|
||||||
|
|
||||||
echo "⚠️ Possible flaky test but confidence too low ($CONFIDENCE)"
|
|
||||||
echo "Not retrying automatically - manual review recommended"
|
|
||||||
|
|
||||||
# Comment on PR if this was a PR build
|
|
||||||
- name: Comment on PR
|
|
||||||
if: github.event.workflow_run.event == 'pull_request'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
OUTPUT='${{ steps.detect.outputs.structured_output }}'
|
|
||||||
IS_FLAKY=$(echo "$OUTPUT" | jq -r '.is_flaky')
|
|
||||||
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
|
|
||||||
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
|
|
||||||
|
|
||||||
pr_number=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" --json number --jq '.[0].number')
|
|
||||||
|
|
||||||
if [ -n "$pr_number" ]; then
|
|
||||||
if [ "$IS_FLAKY" = "true" ]; then
|
|
||||||
TITLE="🔄 Flaky Test Detected"
|
|
||||||
ACTION="✅ Automatically retrying the workflow"
|
|
||||||
else
|
|
||||||
TITLE="❌ Test Failure"
|
|
||||||
ACTION="⚠️ This appears to be a real bug - manual intervention needed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
gh pr comment "$pr_number" --body "$(cat <<EOF
|
|
||||||
## $TITLE
|
|
||||||
|
|
||||||
**Analysis**: $SUMMARY
|
|
||||||
**Confidence**: $CONFIDENCE
|
|
||||||
|
|
||||||
$ACTION
|
|
||||||
|
|
||||||
[View workflow run](${{ github.event.workflow_run.html_url }})
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Claude Code Custom App",
|
|
||||||
"description": "Custom GitHub App for Claude Code Action - AI-powered coding assistant for GitHub workflows",
|
|
||||||
"url": "https://github.com/anthropics/claude-code-action",
|
|
||||||
"hook_attributes": {
|
|
||||||
"url": "https://example.com/github/webhook",
|
|
||||||
"active": false
|
|
||||||
},
|
|
||||||
"redirect_url": "https://github.com/settings/apps/new",
|
|
||||||
"callback_urls": [],
|
|
||||||
"setup_url": "https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md",
|
|
||||||
"public": false,
|
|
||||||
"default_permissions": {
|
|
||||||
"contents": "write",
|
|
||||||
"issues": "write",
|
|
||||||
"pull_requests": "write",
|
|
||||||
"actions": "read",
|
|
||||||
"metadata": "read"
|
|
||||||
},
|
|
||||||
"default_events": [
|
|
||||||
"issue_comment",
|
|
||||||
"issues",
|
|
||||||
"pull_request",
|
|
||||||
"pull_request_review",
|
|
||||||
"pull_request_review_comment"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
"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.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",
|
||||||
|
|||||||
123
scripts/setup-network-restrictions.sh
Executable file
123
scripts/setup-network-restrictions.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Setup Network Restrictions with Squid Proxy
|
||||||
|
# This script sets up a Squid proxy to restrict network access to whitelisted domains only.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if experimental_allowed_domains is provided
|
||||||
|
if [ -z "$EXPERIMENTAL_ALLOWED_DOMAINS" ]; then
|
||||||
|
echo "ERROR: EXPERIMENTAL_ALLOWED_DOMAINS environment variable is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check required environment variables
|
||||||
|
if [ -z "$RUNNER_TEMP" ]; then
|
||||||
|
echo "ERROR: RUNNER_TEMP environment variable is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$GITHUB_ENV" ]; then
|
||||||
|
echo "ERROR: GITHUB_ENV environment variable is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Setting up network restrictions with Squid proxy..."
|
||||||
|
|
||||||
|
SQUID_START_TIME=$(date +%s.%N)
|
||||||
|
|
||||||
|
# Create whitelist file
|
||||||
|
echo "$EXPERIMENTAL_ALLOWED_DOMAINS" > $RUNNER_TEMP/whitelist.txt
|
||||||
|
|
||||||
|
# Ensure each domain has proper format
|
||||||
|
# If domain doesn't start with a dot and isn't an IP, add the dot for subdomain matching
|
||||||
|
mv $RUNNER_TEMP/whitelist.txt $RUNNER_TEMP/whitelist.txt.orig
|
||||||
|
while IFS= read -r domain; do
|
||||||
|
if [ -n "$domain" ]; then
|
||||||
|
# Trim whitespace
|
||||||
|
domain=$(echo "$domain" | xargs)
|
||||||
|
# If it's not empty and doesn't start with a dot, add one
|
||||||
|
if [[ "$domain" != .* ]] && [[ ! "$domain" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo ".$domain" >> $RUNNER_TEMP/whitelist.txt
|
||||||
|
else
|
||||||
|
echo "$domain" >> $RUNNER_TEMP/whitelist.txt
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < $RUNNER_TEMP/whitelist.txt.orig
|
||||||
|
|
||||||
|
# Create Squid config with whitelist
|
||||||
|
echo "http_port 3128" > $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Define ACLs" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl whitelist dstdomain \"/etc/squid/whitelist.txt\"" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl localnet src 127.0.0.1/32" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl localnet src 172.17.0.0/16" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl SSL_ports port 443" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl Safe_ports port 80" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl Safe_ports port 443" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl CONNECT method CONNECT" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Deny requests to certain unsafe ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access deny !Safe_ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Only allow CONNECT to SSL ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access deny CONNECT !SSL_ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Allow localhost" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access allow localhost" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Allow localnet access to whitelisted domains" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access allow localnet whitelist" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Deny everything else" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access deny all" >> $RUNNER_TEMP/squid.conf
|
||||||
|
|
||||||
|
echo "Starting Squid proxy..."
|
||||||
|
# First, remove any existing container
|
||||||
|
sudo docker rm -f squid-proxy 2>/dev/null || true
|
||||||
|
|
||||||
|
# Ensure whitelist file is not empty (Squid fails with empty files)
|
||||||
|
if [ ! -s "$RUNNER_TEMP/whitelist.txt" ]; then
|
||||||
|
echo "WARNING: Whitelist file is empty, adding a dummy entry"
|
||||||
|
echo ".example.com" >> $RUNNER_TEMP/whitelist.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use sudo to prevent Claude from stopping the container
|
||||||
|
CONTAINER_ID=$(sudo docker run -d \
|
||||||
|
--name squid-proxy \
|
||||||
|
-p 127.0.0.1:3128:3128 \
|
||||||
|
-v $RUNNER_TEMP/squid.conf:/etc/squid/squid.conf:ro \
|
||||||
|
-v $RUNNER_TEMP/whitelist.txt:/etc/squid/whitelist.txt:ro \
|
||||||
|
ubuntu/squid:latest 2>&1) || {
|
||||||
|
echo "ERROR: Failed to start Squid container"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for proxy to be ready (usually < 1 second)
|
||||||
|
READY=false
|
||||||
|
for i in {1..30}; do
|
||||||
|
if nc -z 127.0.0.1 3128 2>/dev/null; then
|
||||||
|
TOTAL_TIME=$(echo "scale=3; $(date +%s.%N) - $SQUID_START_TIME" | bc)
|
||||||
|
echo "Squid proxy ready in ${TOTAL_TIME}s"
|
||||||
|
READY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$READY" != "true" ]; then
|
||||||
|
echo "ERROR: Squid proxy failed to start within 3 seconds"
|
||||||
|
echo "Container logs:"
|
||||||
|
sudo docker logs squid-proxy 2>&1 || true
|
||||||
|
echo "Container status:"
|
||||||
|
sudo docker ps -a | grep squid-proxy || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set proxy environment variables
|
||||||
|
echo "http_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
echo "https_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
echo "HTTP_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
echo "HTTPS_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
echo "Network restrictions setup completed successfully"
|
||||||
@@ -192,6 +192,11 @@ export function prepareContext(
|
|||||||
if (!isPR) {
|
if (!isPR) {
|
||||||
throw new Error("IS_PR must be true for pull_request_review event");
|
throw new Error("IS_PR must be true for pull_request_review event");
|
||||||
}
|
}
|
||||||
|
if (!commentBody) {
|
||||||
|
throw new Error(
|
||||||
|
"COMMENT_BODY is required for pull_request_review event",
|
||||||
|
);
|
||||||
|
}
|
||||||
eventData = {
|
eventData = {
|
||||||
eventName: "pull_request_review",
|
eventName: "pull_request_review",
|
||||||
isPR: true,
|
isPR: true,
|
||||||
@@ -459,123 +464,6 @@ export function generatePrompt(
|
|||||||
return mode.generatePrompt(context, githubData, useCommitSigning);
|
return mode.generatePrompt(context, githubData, useCommitSigning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a simplified prompt for tag mode (opt-in via USE_SIMPLE_PROMPT env var)
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
function generateSimplePrompt(
|
|
||||||
context: PreparedContext,
|
|
||||||
githubData: FetchDataResult,
|
|
||||||
useCommitSigning: boolean = false,
|
|
||||||
): string {
|
|
||||||
const {
|
|
||||||
contextData,
|
|
||||||
comments,
|
|
||||||
changedFilesWithSHA,
|
|
||||||
reviewData,
|
|
||||||
imageUrlMap,
|
|
||||||
} = githubData;
|
|
||||||
const { eventData } = context;
|
|
||||||
|
|
||||||
const { triggerContext } = getEventTypeAndContext(context);
|
|
||||||
|
|
||||||
const formattedContext = formatContext(contextData, eventData.isPR);
|
|
||||||
const formattedComments = formatComments(comments, imageUrlMap);
|
|
||||||
const formattedReviewComments = eventData.isPR
|
|
||||||
? formatReviewComments(reviewData, imageUrlMap)
|
|
||||||
: "";
|
|
||||||
const formattedChangedFiles = eventData.isPR
|
|
||||||
? formatChangedFilesWithSHA(changedFilesWithSHA)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const hasImages = imageUrlMap && imageUrlMap.size > 0;
|
|
||||||
const imagesInfo = hasImages
|
|
||||||
? `\n\n<images_info>
|
|
||||||
Images from comments have been saved to disk. Paths are in the formatted content above. Use Read tool to view them.
|
|
||||||
</images_info>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const formattedBody = contextData?.body
|
|
||||||
? formatBody(contextData.body, imageUrlMap)
|
|
||||||
: "No description provided";
|
|
||||||
|
|
||||||
const entityType = eventData.isPR ? "pull request" : "issue";
|
|
||||||
const jobUrl = `${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
||||||
|
|
||||||
let promptContent = `You were tagged on a GitHub ${entityType} via "${context.triggerPhrase}". Read the request and decide how to help.
|
|
||||||
|
|
||||||
<context>
|
|
||||||
${formattedContext}
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<${eventData.isPR ? "pr" : "issue"}_body>
|
|
||||||
${formattedBody}
|
|
||||||
</${eventData.isPR ? "pr" : "issue"}_body>
|
|
||||||
|
|
||||||
<comments>
|
|
||||||
${formattedComments || "No comments"}
|
|
||||||
</comments>
|
|
||||||
${
|
|
||||||
eventData.isPR
|
|
||||||
? `
|
|
||||||
<review_comments>
|
|
||||||
${formattedReviewComments || "No review comments"}
|
|
||||||
</review_comments>
|
|
||||||
|
|
||||||
<changed_files>
|
|
||||||
${formattedChangedFiles || "No files changed"}
|
|
||||||
</changed_files>`
|
|
||||||
: ""
|
|
||||||
}${imagesInfo}
|
|
||||||
|
|
||||||
<metadata>
|
|
||||||
repository: ${context.repository}
|
|
||||||
${eventData.isPR && eventData.prNumber ? `pr_number: ${eventData.prNumber}` : ""}
|
|
||||||
${!eventData.isPR && eventData.issueNumber ? `issue_number: ${eventData.issueNumber}` : ""}
|
|
||||||
trigger: ${triggerContext}
|
|
||||||
triggered_by: ${context.triggerUsername ?? "Unknown"}
|
|
||||||
claude_comment_id: ${context.claudeCommentId}
|
|
||||||
</metadata>
|
|
||||||
${
|
|
||||||
(eventData.eventName === "issue_comment" ||
|
|
||||||
eventData.eventName === "pull_request_review_comment" ||
|
|
||||||
eventData.eventName === "pull_request_review") &&
|
|
||||||
eventData.commentBody
|
|
||||||
? `
|
|
||||||
<trigger_comment>
|
|
||||||
${sanitizeContent(eventData.commentBody)}
|
|
||||||
</trigger_comment>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Your request is in <trigger_comment> above${eventData.eventName === "issues" ? ` (or the ${entityType} body for assigned/labeled events)` : ""}.
|
|
||||||
|
|
||||||
Decide what's being asked:
|
|
||||||
1. **Question or code review** - Answer directly or provide feedback
|
|
||||||
2. **Code change** - Implement the change, commit, and push
|
|
||||||
|
|
||||||
Communication:
|
|
||||||
- Your ONLY visible output is your GitHub comment - update it with progress and results
|
|
||||||
- Use mcp__github_comment__update_claude_comment to update (only "body" param needed)
|
|
||||||
- Use checklist format for tasks: - [ ] incomplete, - [x] complete
|
|
||||||
- Use ### headers (not #)
|
|
||||||
${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
|
|
||||||
${
|
|
||||||
eventData.claudeBranch
|
|
||||||
? `
|
|
||||||
When done with changes, provide a PR link:
|
|
||||||
[Create a PR](${GITHUB_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...${eventData.claudeBranch}?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
|
||||||
Use THREE dots (...) between branches. URL-encode all parameters.`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Always include at the bottom:
|
|
||||||
- Job link: [View job run](${jobUrl})
|
|
||||||
- Follow the repo's CLAUDE.md file for project-specific guidelines`;
|
|
||||||
|
|
||||||
return promptContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the default prompt for tag mode
|
* Generates the default prompt for tag mode
|
||||||
* @internal
|
* @internal
|
||||||
@@ -585,10 +473,6 @@ export function generateDefaultPrompt(
|
|||||||
githubData: FetchDataResult,
|
githubData: FetchDataResult,
|
||||||
useCommitSigning: boolean = false,
|
useCommitSigning: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
// Use simplified prompt if opted in
|
|
||||||
if (process.env.USE_SIMPLE_PROMPT === "true") {
|
|
||||||
return generateSimplePrompt(context, githubData, useCommitSigning);
|
|
||||||
}
|
|
||||||
const {
|
const {
|
||||||
contextData,
|
contextData,
|
||||||
comments,
|
comments,
|
||||||
@@ -800,7 +684,7 @@ ${
|
|||||||
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
||||||
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
||||||
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
||||||
- Your comment must always include the job run link in the format "[View job run](${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID})" at the bottom of your response (branch link if there is one should also be included there).
|
- Your comment must always include the job run link (and branch link if there is one) at the bottom.
|
||||||
|
|
||||||
CAPABILITIES AND LIMITATIONS:
|
CAPABILITIES AND LIMITATIONS:
|
||||||
When users ask you to do something, be aware of what you can and cannot do. This section helps you understand how to respond when users request actions outside your scope.
|
When users ask you to do something, be aware of what you can and cannot do. This section helps you understand how to respond when users request actions outside your scope.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type PullRequestReviewEvent = {
|
|||||||
eventName: "pull_request_review";
|
eventName: "pull_request_review";
|
||||||
isPR: true;
|
isPR: true;
|
||||||
prNumber: string;
|
prNumber: string;
|
||||||
commentBody?: string; // May be absent for approvals without comments
|
commentBody: string;
|
||||||
claudeBranch?: string;
|
claudeBranch?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function collectActionInputsPresence(): void {
|
|||||||
max_turns: "",
|
max_turns: "",
|
||||||
use_sticky_comment: "false",
|
use_sticky_comment: "false",
|
||||||
use_commit_signing: "false",
|
use_commit_signing: "false",
|
||||||
|
experimental_allowed_domains: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const allInputsJson = process.env.ALL_INPUTS;
|
const allInputsJson = process.env.ALL_INPUTS;
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ async function run() {
|
|||||||
|
|
||||||
// Check if action failed and read output file for execution details
|
// Check if action failed and read output file for execution details
|
||||||
let executionDetails: {
|
let executionDetails: {
|
||||||
total_cost_usd?: number;
|
cost_usd?: number;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
duration_api_ms?: number;
|
duration_api_ms?: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
@@ -179,11 +179,11 @@ async function run() {
|
|||||||
const lastElement = outputData[outputData.length - 1];
|
const lastElement = outputData[outputData.length - 1];
|
||||||
if (
|
if (
|
||||||
lastElement.type === "result" &&
|
lastElement.type === "result" &&
|
||||||
"total_cost_usd" in lastElement &&
|
"cost_usd" in lastElement &&
|
||||||
"duration_ms" in lastElement
|
"duration_ms" in lastElement
|
||||||
) {
|
) {
|
||||||
executionDetails = {
|
executionDetails = {
|
||||||
total_cost_usd: lastElement.total_cost_usd,
|
cost_usd: lastElement.cost_usd,
|
||||||
duration_ms: lastElement.duration_ms,
|
duration_ms: lastElement.duration_ms,
|
||||||
duration_api_ms: lastElement.duration_api_ms,
|
duration_api_ms: lastElement.duration_api_ms,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ export const PR_QUERY = `
|
|||||||
headRefName
|
headRefName
|
||||||
headRefOid
|
headRefOid
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
|
||||||
lastEditedAt
|
|
||||||
additions
|
additions
|
||||||
deletions
|
deletions
|
||||||
state
|
state
|
||||||
@@ -98,8 +96,6 @@ export const ISSUE_QUERY = `
|
|||||||
login
|
login
|
||||||
}
|
}
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
|
||||||
lastEditedAt
|
|
||||||
state
|
state
|
||||||
comments(first: 100) {
|
comments(first: 100) {
|
||||||
nodes {
|
nodes {
|
||||||
|
|||||||
@@ -107,38 +107,6 @@ export function filterReviewsToTriggerTime<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the issue/PR body was edited after the trigger time.
|
|
||||||
* This prevents a race condition where an attacker could edit the issue/PR body
|
|
||||||
* between when an authorized user triggered Claude and when Claude processes the request.
|
|
||||||
*
|
|
||||||
* @param contextData - The PR or issue data containing body and edit timestamps
|
|
||||||
* @param triggerTime - ISO timestamp of when the trigger event occurred
|
|
||||||
* @returns true if the body is safe to use, false if it was edited after trigger
|
|
||||||
*/
|
|
||||||
export function isBodySafeToUse(
|
|
||||||
contextData: { createdAt: string; updatedAt?: string; lastEditedAt?: string },
|
|
||||||
triggerTime: string | undefined,
|
|
||||||
): boolean {
|
|
||||||
// If no trigger time is available, we can't validate - allow the body
|
|
||||||
// This maintains backwards compatibility for triggers that don't have timestamps
|
|
||||||
if (!triggerTime) return true;
|
|
||||||
|
|
||||||
const triggerTimestamp = new Date(triggerTime).getTime();
|
|
||||||
|
|
||||||
// Check if the body was edited after the trigger
|
|
||||||
// Use lastEditedAt if available (more accurate for body edits), otherwise fall back to updatedAt
|
|
||||||
const lastEditTime = contextData.lastEditedAt || contextData.updatedAt;
|
|
||||||
if (lastEditTime) {
|
|
||||||
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
|
||||||
if (lastEditTimestamp >= triggerTimestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchDataParams = {
|
type FetchDataParams = {
|
||||||
octokits: Octokits;
|
octokits: Octokits;
|
||||||
repository: string;
|
repository: string;
|
||||||
@@ -305,13 +273,9 @@ export async function fetchGitHubData({
|
|||||||
body: c.body,
|
body: c.body,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add the main issue/PR body if it has content and wasn't edited after trigger
|
// Add the main issue/PR body if it has content
|
||||||
// This prevents a TOCTOU race condition where an attacker could edit the body
|
const mainBody: CommentWithImages[] = contextData.body
|
||||||
// between when an authorized user triggered Claude and when Claude processes the request
|
? [
|
||||||
let mainBody: CommentWithImages[] = [];
|
|
||||||
if (contextData.body) {
|
|
||||||
if (isBodySafeToUse(contextData, triggerTime)) {
|
|
||||||
mainBody = [
|
|
||||||
{
|
{
|
||||||
...(isPR
|
...(isPR
|
||||||
? {
|
? {
|
||||||
@@ -325,14 +289,8 @@ export async function fetchGitHubData({
|
|||||||
body: contextData.body,
|
body: contextData.body,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
} else {
|
: [];
|
||||||
console.warn(
|
|
||||||
`Security: ${isPR ? "PR" : "Issue"} #${prNumber} body was edited after the trigger event. ` +
|
|
||||||
`Excluding body content to prevent potential injection attacks.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allComments = [
|
const allComments = [
|
||||||
...mainBody,
|
...mainBody,
|
||||||
|
|||||||
@@ -6,112 +6,13 @@
|
|||||||
* - For Issues: Create a new branch
|
* - For Issues: Create a new branch
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from "child_process";
|
import { $ } from "bun";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import type { ParsedGitHubContext } from "../context";
|
import type { ParsedGitHubContext } from "../context";
|
||||||
import type { GitHubPullRequest } from "../types";
|
import type { GitHubPullRequest } from "../types";
|
||||||
import type { Octokits } from "../api/client";
|
import type { Octokits } from "../api/client";
|
||||||
import type { FetchDataResult } from "../data/fetcher";
|
import type { FetchDataResult } from "../data/fetcher";
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a git branch name against a strict whitelist pattern.
|
|
||||||
* This prevents command injection by ensuring only safe characters are used.
|
|
||||||
*
|
|
||||||
* Valid branch names:
|
|
||||||
* - Start with alphanumeric character (not dash, to prevent option injection)
|
|
||||||
* - Contain only alphanumeric, forward slash, hyphen, underscore, or period
|
|
||||||
* - Do not start or end with a period
|
|
||||||
* - Do not end with a slash
|
|
||||||
* - Do not contain '..' (path traversal)
|
|
||||||
* - Do not contain '//' (consecutive slashes)
|
|
||||||
* - Do not end with '.lock'
|
|
||||||
* - Do not contain '@{'
|
|
||||||
* - Do not contain control characters or special git characters (~^:?*[\])
|
|
||||||
*/
|
|
||||||
export function validateBranchName(branchName: string): void {
|
|
||||||
// Check for empty or whitespace-only names
|
|
||||||
if (!branchName || branchName.trim().length === 0) {
|
|
||||||
throw new Error("Branch name cannot be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for leading dash (prevents option injection like --help, -x)
|
|
||||||
if (branchName.startsWith("-")) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot start with a dash.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for control characters and special git characters (~^:?*[\])
|
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
if (/[\x00-\x1F\x7F ~^:?*[\]\\]/.test(branchName)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot contain control characters, spaces, or special git characters (~^:?*[\\]).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period
|
|
||||||
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/;
|
|
||||||
|
|
||||||
if (!validPattern.test(branchName)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, or periods.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for leading/trailing periods
|
|
||||||
if (branchName.startsWith(".") || branchName.endsWith(".")) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot start or end with a period.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for trailing slash
|
|
||||||
if (branchName.endsWith("/")) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot end with a slash.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for consecutive slashes
|
|
||||||
if (branchName.includes("//")) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot contain consecutive slashes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional git-specific validations
|
|
||||||
if (branchName.includes("..")) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot contain '..'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (branchName.endsWith(".lock")) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot end with '.lock'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (branchName.includes("@{")) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes a git command safely using execFileSync to avoid shell interpolation.
|
|
||||||
*
|
|
||||||
* Security: execFileSync passes arguments directly to the git binary without
|
|
||||||
* invoking a shell, preventing command injection attacks where malicious input
|
|
||||||
* could be interpreted as shell commands (e.g., branch names containing `;`, `|`, `&&`).
|
|
||||||
*
|
|
||||||
* @param args - Git command arguments (e.g., ["checkout", "branch-name"])
|
|
||||||
*/
|
|
||||||
function execGit(args: string[]): void {
|
|
||||||
execFileSync("git", args, { stdio: "inherit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BranchInfo = {
|
export type BranchInfo = {
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
claudeBranch?: string;
|
claudeBranch?: string;
|
||||||
@@ -152,19 +53,14 @@ export async function setupBranch(
|
|||||||
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate branch names before use to prevent command injection
|
|
||||||
validateBranchName(branchName);
|
|
||||||
|
|
||||||
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
||||||
// Using execFileSync instead of shell template literals for security
|
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
|
||||||
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
|
await $`git checkout ${branchName} --`;
|
||||||
execGit(["checkout", branchName, "--"]);
|
|
||||||
|
|
||||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||||
|
|
||||||
// For open PRs, we need to get the base branch of the PR
|
// For open PRs, we need to get the base branch of the PR
|
||||||
const baseBranch = prData.baseRefName;
|
const baseBranch = prData.baseRefName;
|
||||||
validateBranchName(baseBranch);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseBranch,
|
baseBranch,
|
||||||
@@ -222,9 +118,8 @@ export async function setupBranch(
|
|||||||
|
|
||||||
// Ensure we're on the source branch
|
// Ensure we're on the source branch
|
||||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||||
validateBranchName(sourceBranch);
|
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
await $`git checkout ${sourceBranch}`;
|
||||||
execGit(["checkout", sourceBranch, "--"]);
|
|
||||||
|
|
||||||
// Set outputs for GitHub Actions
|
// Set outputs for GitHub Actions
|
||||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||||
@@ -243,13 +138,11 @@ export async function setupBranch(
|
|||||||
|
|
||||||
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
||||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||||
validateBranchName(sourceBranch);
|
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||||
validateBranchName(newBranch);
|
await $`git checkout ${sourceBranch}`;
|
||||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
|
||||||
execGit(["checkout", sourceBranch, "--"]);
|
|
||||||
|
|
||||||
// Create and checkout the new branch from the source branch
|
// Create and checkout the new branch from the source branch
|
||||||
execGit(["checkout", "-b", newBranch]);
|
await $`git checkout -b ${newBranch}`;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Successfully created and checked out local branch: ${newBranch}`,
|
`Successfully created and checked out local branch: ${newBranch}`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
export type ExecutionDetails = {
|
export type ExecutionDetails = {
|
||||||
total_cost_usd?: number;
|
cost_usd?: number;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
duration_api_ms?: number;
|
duration_api_ms?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ export type GitHubPullRequest = {
|
|||||||
headRefName: string;
|
headRefName: string;
|
||||||
headRefOid: string;
|
headRefOid: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
|
||||||
lastEditedAt?: string;
|
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
state: string;
|
state: string;
|
||||||
@@ -85,8 +83,6 @@ export type GitHubIssue = {
|
|||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor;
|
author: GitHubAuthor;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
|
||||||
lastEditedAt?: string;
|
|
||||||
state: string;
|
state: string;
|
||||||
comments: {
|
comments: {
|
||||||
nodes: GitHubComment[];
|
nodes: GitHubComment[];
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ describe("updateCommentBody", () => {
|
|||||||
const input = {
|
const input = {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
executionDetails: {
|
executionDetails: {
|
||||||
total_cost_usd: 0.13382595,
|
cost_usd: 0.13382595,
|
||||||
duration_ms: 31033,
|
duration_ms: 31033,
|
||||||
duration_api_ms: 31034,
|
duration_api_ms: 31034,
|
||||||
},
|
},
|
||||||
@@ -301,7 +301,7 @@ describe("updateCommentBody", () => {
|
|||||||
const input = {
|
const input = {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
executionDetails: {
|
executionDetails: {
|
||||||
total_cost_usd: 0.25,
|
cost_usd: 0.25,
|
||||||
},
|
},
|
||||||
triggerUsername: "testuser",
|
triggerUsername: "testuser",
|
||||||
};
|
};
|
||||||
@@ -322,7 +322,7 @@ describe("updateCommentBody", () => {
|
|||||||
branchName: "claude-branch-123",
|
branchName: "claude-branch-123",
|
||||||
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
|
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
|
||||||
executionDetails: {
|
executionDetails: {
|
||||||
total_cost_usd: 0.01,
|
cost_usd: 0.01,
|
||||||
duration_ms: 65000, // 1 minute 5 seconds
|
duration_ms: 65000, // 1 minute 5 seconds
|
||||||
},
|
},
|
||||||
triggerUsername: "trigger-user",
|
triggerUsername: "trigger-user",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
filterCommentsToTriggerTime,
|
filterCommentsToTriggerTime,
|
||||||
filterReviewsToTriggerTime,
|
filterReviewsToTriggerTime,
|
||||||
isBodySafeToUse,
|
|
||||||
} from "../src/github/data/fetcher";
|
} from "../src/github/data/fetcher";
|
||||||
import {
|
import {
|
||||||
createMockContext,
|
createMockContext,
|
||||||
@@ -372,139 +371,6 @@ describe("filterReviewsToTriggerTime", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isBodySafeToUse", () => {
|
|
||||||
const triggerTime = "2024-01-15T12:00:00Z";
|
|
||||||
|
|
||||||
const createMockContextData = (
|
|
||||||
createdAt: string,
|
|
||||||
updatedAt?: string,
|
|
||||||
lastEditedAt?: string,
|
|
||||||
) => ({
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
lastEditedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("body edit time validation", () => {
|
|
||||||
it("should return true when body was never edited", () => {
|
|
||||||
const contextData = createMockContextData("2024-01-15T10:00:00Z");
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true when body was edited before trigger time", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T11:00:00Z",
|
|
||||||
"2024-01-15T11:30:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when body was edited after trigger time (using updatedAt)", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T13:00:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when body was edited after trigger time (using lastEditedAt)", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
undefined,
|
|
||||||
"2024-01-15T13:00:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when body was edited exactly at trigger time", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T12:00:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prioritize lastEditedAt over updatedAt", () => {
|
|
||||||
// updatedAt is after trigger, but lastEditedAt is before - should be safe
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T13:00:00Z", // updatedAt after trigger
|
|
||||||
"2024-01-15T11:00:00Z", // lastEditedAt before trigger
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("should return true when no trigger time is provided (backward compatibility)", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T13:00:00Z", // Would normally fail
|
|
||||||
"2024-01-15T14:00:00Z", // Would normally fail
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle millisecond precision correctly", () => {
|
|
||||||
// Edit 1ms after trigger - should be unsafe
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T12:00:00.001Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle edit 1ms before trigger - should be safe", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T11:59:59.999Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle various ISO timestamp formats", () => {
|
|
||||||
const contextData1 = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T11:00:00Z",
|
|
||||||
);
|
|
||||||
const contextData2 = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00+00:00",
|
|
||||||
"2024-01-15T11:00:00+00:00",
|
|
||||||
);
|
|
||||||
const contextData3 = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00.000Z",
|
|
||||||
"2024-01-15T11:00:00.000Z",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(isBodySafeToUse(contextData1, triggerTime)).toBe(true);
|
|
||||||
expect(isBodySafeToUse(contextData2, triggerTime)).toBe(true);
|
|
||||||
expect(isBodySafeToUse(contextData3, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security scenarios", () => {
|
|
||||||
it("should detect race condition attack - body edited between trigger and processing", () => {
|
|
||||||
// Simulates: Owner triggers @claude at 12:00, attacker edits body at 12:00:30
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z", // Issue created
|
|
||||||
"2024-01-15T12:00:30Z", // Body edited after trigger
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow body that was stable at trigger time", () => {
|
|
||||||
// Body was last edited well before the trigger
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T10:30:00Z",
|
|
||||||
"2024-01-15T10:30:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchGitHubData integration with time filtering", () => {
|
describe("fetchGitHubData integration with time filtering", () => {
|
||||||
it("should filter comments based on trigger time when provided", async () => {
|
it("should filter comments based on trigger time when provided", async () => {
|
||||||
const mockOctokits = {
|
const mockOctokits = {
|
||||||
@@ -830,119 +696,4 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
// All three comments should be included as they're all before trigger time
|
// All three comments should be included as they're all before trigger time
|
||||||
expect(result.comments.length).toBe(3);
|
expect(result.comments.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should exclude issue body when edited after trigger time (TOCTOU protection)", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
issue: {
|
|
||||||
number: 555,
|
|
||||||
title: "Test Issue",
|
|
||||||
body: "Malicious body edited after trigger",
|
|
||||||
author: { login: "attacker" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
comments: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "555",
|
|
||||||
isPR: false,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The body should be excluded from image processing due to TOCTOU protection
|
|
||||||
// We can verify this by checking that issue_body is NOT in the imageUrlMap keys
|
|
||||||
const hasIssueBodyInMap = Array.from(result.imageUrlMap.keys()).some(
|
|
||||||
(key) => key.includes("issue_body"),
|
|
||||||
);
|
|
||||||
expect(hasIssueBodyInMap).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include issue body when not edited after trigger time", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
issue: {
|
|
||||||
number: 666,
|
|
||||||
title: "Test Issue",
|
|
||||||
body: "Safe body not edited after trigger",
|
|
||||||
author: { login: "author" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T11:00:00Z", // Edited before trigger
|
|
||||||
lastEditedAt: "2024-01-15T11:00:00Z", // Edited before trigger
|
|
||||||
comments: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "666",
|
|
||||||
isPR: false,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The contextData should still contain the body
|
|
||||||
expect(result.contextData.body).toBe("Safe body not edited after trigger");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should exclude PR body when edited after trigger time (TOCTOU protection)", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
pullRequest: {
|
|
||||||
number: 777,
|
|
||||||
title: "Test PR",
|
|
||||||
body: "Malicious PR body edited after trigger",
|
|
||||||
author: { login: "attacker" },
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature",
|
|
||||||
headRefOid: "abc123",
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
additions: 10,
|
|
||||||
deletions: 5,
|
|
||||||
state: "OPEN",
|
|
||||||
commits: { totalCount: 1, nodes: [] },
|
|
||||||
files: { nodes: [] },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
reviews: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "777",
|
|
||||||
isPR: true,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The body should be excluded from image processing due to TOCTOU protection
|
|
||||||
const hasPrBodyInMap = Array.from(result.imageUrlMap.keys()).some((key) =>
|
|
||||||
key.includes("pr_body"),
|
|
||||||
);
|
|
||||||
expect(hasPrBodyInMap).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
2
test/fixtures/sample-turns.json
vendored
2
test/fixtures/sample-turns.json
vendored
@@ -189,7 +189,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "result",
|
"type": "result",
|
||||||
"total_cost_usd": 0.0347,
|
"cost_usd": 0.0347,
|
||||||
"duration_ms": 18750,
|
"duration_ms": 18750,
|
||||||
"result": "Successfully removed debug print statement from file and added review comment to document the change."
|
"result": "Successfully removed debug print statement from file and added review comment to document the change."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,53 +401,6 @@ export const mockPullRequestReviewContext: ParsedGitHubContext = {
|
|||||||
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
|
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockPullRequestReviewWithoutCommentContext: ParsedGitHubContext = {
|
|
||||||
runId: "1234567890",
|
|
||||||
eventName: "pull_request_review",
|
|
||||||
eventAction: "dismissed",
|
|
||||||
repository: defaultRepository,
|
|
||||||
actor: "senior-developer",
|
|
||||||
payload: {
|
|
||||||
action: "submitted",
|
|
||||||
review: {
|
|
||||||
id: 11122233,
|
|
||||||
body: null, // Simulating approval without comment
|
|
||||||
user: {
|
|
||||||
login: "senior-developer",
|
|
||||||
id: 44444,
|
|
||||||
avatar_url: "https://avatars.githubusercontent.com/u/44444",
|
|
||||||
html_url: "https://github.com/senior-developer",
|
|
||||||
},
|
|
||||||
state: "approved",
|
|
||||||
html_url:
|
|
||||||
"https://github.com/test-owner/test-repo/pull/321#pullrequestreview-11122233",
|
|
||||||
submitted_at: "2024-01-15T15:30:00Z",
|
|
||||||
},
|
|
||||||
pull_request: {
|
|
||||||
number: 321,
|
|
||||||
title: "Refactor: Improve error handling in API layer",
|
|
||||||
body: "This PR improves error handling across all API endpoints",
|
|
||||||
user: {
|
|
||||||
login: "backend-developer",
|
|
||||||
id: 33333,
|
|
||||||
avatar_url: "https://avatars.githubusercontent.com/u/33333",
|
|
||||||
html_url: "https://github.com/backend-developer",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
repository: {
|
|
||||||
name: "test-repo",
|
|
||||||
full_name: "test-owner/test-repo",
|
|
||||||
private: false,
|
|
||||||
owner: {
|
|
||||||
login: "test-owner",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as PullRequestReviewEvent,
|
|
||||||
entityNumber: 321,
|
|
||||||
isPR: true,
|
|
||||||
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const mockPullRequestReviewCommentContext: ParsedGitHubContext = {
|
export const mockPullRequestReviewCommentContext: ParsedGitHubContext = {
|
||||||
runId: "1234567890",
|
runId: "1234567890",
|
||||||
eventName: "pull_request_review_comment",
|
eventName: "pull_request_review_comment",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
mockPullRequestCommentContext,
|
mockPullRequestCommentContext,
|
||||||
mockPullRequestReviewContext,
|
mockPullRequestReviewContext,
|
||||||
mockPullRequestReviewCommentContext,
|
mockPullRequestReviewCommentContext,
|
||||||
mockPullRequestReviewWithoutCommentContext,
|
|
||||||
} from "./mockContext";
|
} from "./mockContext";
|
||||||
|
|
||||||
const BASE_ENV = {
|
const BASE_ENV = {
|
||||||
@@ -127,24 +126,6 @@ describe("parseEnvVarsWithContext", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pull_request_review event without comment", () => {
|
|
||||||
test("should parse pull_request_review event correctly", () => {
|
|
||||||
process.env = BASE_ENV;
|
|
||||||
const result = prepareContext(
|
|
||||||
mockPullRequestReviewWithoutCommentContext,
|
|
||||||
"12345",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.eventData.eventName).toBe("pull_request_review");
|
|
||||||
expect(result.eventData.isPR).toBe(true);
|
|
||||||
expect(result.triggerUsername).toBe("senior-developer");
|
|
||||||
if (result.eventData.eventName === "pull_request_review") {
|
|
||||||
expect(result.eventData.prNumber).toBe("321");
|
|
||||||
expect(result.eventData.commentBody).toBe("");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pull_request_review_comment event", () => {
|
describe("pull_request_review_comment event", () => {
|
||||||
test("should parse pull_request_review_comment event correctly", () => {
|
test("should parse pull_request_review_comment event correctly", () => {
|
||||||
process.env = BASE_ENV;
|
process.env = BASE_ENV;
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
|
||||||
import { validateBranchName } from "../src/github/operations/branch";
|
|
||||||
|
|
||||||
describe("validateBranchName", () => {
|
|
||||||
describe("valid branch names", () => {
|
|
||||||
it("should accept simple alphanumeric names", () => {
|
|
||||||
expect(() => validateBranchName("main")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("feature123")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("Branch1")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept names with hyphens", () => {
|
|
||||||
expect(() => validateBranchName("feature-branch")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("fix-bug-123")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept names with underscores", () => {
|
|
||||||
expect(() => validateBranchName("feature_branch")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("fix_bug_123")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept names with forward slashes", () => {
|
|
||||||
expect(() => validateBranchName("feature/new-thing")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("user/feature/branch")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept names with periods", () => {
|
|
||||||
expect(() => validateBranchName("v1.0.0")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("release.1.2.3")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept typical branch name formats", () => {
|
|
||||||
expect(() =>
|
|
||||||
validateBranchName("claude/issue-123-20250101-1234"),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(() => validateBranchName("refs/heads/main")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("bugfix/JIRA-1234")).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("command injection attempts", () => {
|
|
||||||
it("should reject shell command substitution with $()", () => {
|
|
||||||
expect(() => validateBranchName("$(whoami)")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch-$(rm -rf /)")).toThrow();
|
|
||||||
expect(() => validateBranchName("test$(cat /etc/passwd)")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject shell command substitution with backticks", () => {
|
|
||||||
expect(() => validateBranchName("`whoami`")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch-`rm -rf /`")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject command chaining with semicolons", () => {
|
|
||||||
expect(() => validateBranchName("branch; rm -rf /")).toThrow();
|
|
||||||
expect(() => validateBranchName("test;whoami")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject command chaining with &&", () => {
|
|
||||||
expect(() => validateBranchName("branch && rm -rf /")).toThrow();
|
|
||||||
expect(() => validateBranchName("test&&whoami")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject command chaining with ||", () => {
|
|
||||||
expect(() => validateBranchName("branch || rm -rf /")).toThrow();
|
|
||||||
expect(() => validateBranchName("test||whoami")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject pipe characters", () => {
|
|
||||||
expect(() => validateBranchName("branch | cat")).toThrow();
|
|
||||||
expect(() => validateBranchName("test|grep password")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject redirection operators", () => {
|
|
||||||
expect(() => validateBranchName("branch > /etc/passwd")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch < input")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch >> file")).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("option injection attempts", () => {
|
|
||||||
it("should reject branch names starting with dash", () => {
|
|
||||||
expect(() => validateBranchName("-x")).toThrow(
|
|
||||||
/cannot start with a dash/,
|
|
||||||
);
|
|
||||||
expect(() => validateBranchName("--help")).toThrow(
|
|
||||||
/cannot start with a dash/,
|
|
||||||
);
|
|
||||||
expect(() => validateBranchName("-")).toThrow(/cannot start with a dash/);
|
|
||||||
expect(() => validateBranchName("--version")).toThrow(
|
|
||||||
/cannot start with a dash/,
|
|
||||||
);
|
|
||||||
expect(() => validateBranchName("-rf")).toThrow(
|
|
||||||
/cannot start with a dash/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("path traversal attempts", () => {
|
|
||||||
it("should reject double dot sequences", () => {
|
|
||||||
expect(() => validateBranchName("../../../etc")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch/../secret")).toThrow(/'\.\.'$/);
|
|
||||||
expect(() => validateBranchName("a..b")).toThrow(/'\.\.'$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("git-specific invalid patterns", () => {
|
|
||||||
it("should reject @{ sequence", () => {
|
|
||||||
expect(() => validateBranchName("branch@{1}")).toThrow(/@{/);
|
|
||||||
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject .lock suffix", () => {
|
|
||||||
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
|
|
||||||
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject consecutive slashes", () => {
|
|
||||||
expect(() => validateBranchName("feature//branch")).toThrow(
|
|
||||||
/consecutive slashes/,
|
|
||||||
);
|
|
||||||
expect(() => validateBranchName("a//b//c")).toThrow(
|
|
||||||
/consecutive slashes/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject trailing slashes", () => {
|
|
||||||
expect(() => validateBranchName("feature/")).toThrow(
|
|
||||||
/cannot end with a slash/,
|
|
||||||
);
|
|
||||||
expect(() => validateBranchName("branch/")).toThrow(
|
|
||||||
/cannot end with a slash/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject leading periods", () => {
|
|
||||||
expect(() => validateBranchName(".hidden")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject trailing periods", () => {
|
|
||||||
expect(() => validateBranchName("branch.")).toThrow(
|
|
||||||
/cannot start or end with a period/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject special git refspec characters", () => {
|
|
||||||
expect(() => validateBranchName("branch~1")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch^2")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch:ref")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch?")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch*")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch[0]")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch\\path")).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("control characters and special characters", () => {
|
|
||||||
it("should reject null bytes", () => {
|
|
||||||
expect(() => validateBranchName("branch\x00name")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject other control characters", () => {
|
|
||||||
expect(() => validateBranchName("branch\x01name")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch\x1Fname")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch\x7Fname")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject spaces", () => {
|
|
||||||
expect(() => validateBranchName("branch name")).toThrow();
|
|
||||||
expect(() => validateBranchName("feature branch")).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject newlines and tabs", () => {
|
|
||||||
expect(() => validateBranchName("branch\nname")).toThrow();
|
|
||||||
expect(() => validateBranchName("branch\tname")).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty and whitespace", () => {
|
|
||||||
it("should reject empty strings", () => {
|
|
||||||
expect(() => validateBranchName("")).toThrow(/cannot be empty/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject whitespace-only strings", () => {
|
|
||||||
expect(() => validateBranchName(" ")).toThrow();
|
|
||||||
expect(() => validateBranchName("\t\n")).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("should accept single alphanumeric character", () => {
|
|
||||||
expect(() => validateBranchName("a")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("1")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject single special characters", () => {
|
|
||||||
expect(() => validateBranchName(".")).toThrow();
|
|
||||||
expect(() => validateBranchName("/")).toThrow();
|
|
||||||
expect(() => validateBranchName("-")).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user