Compare commits

..

4 Commits

Author SHA1 Message Date
bogini
6902c227aa feat: add structured output support via --json-schema argument (#687)
* feat: add structured output support

Add support for Agent SDK structured outputs.

New input: json_schema
Output: structured_output (JSON string)
Access: fromJSON(steps.id.outputs.structured_output).field

Docs: https://docs.claude.com/en/docs/agent-sdk/structured-outputs

* rm unused

* refactor: simplify structured outputs to use claude_args

Remove json_schema input in favor of passing --json-schema flag directly
in claude_args. This simplifies the interface by treating structured outputs
like other CLI flags (--model, --max-turns, etc.) instead of as a special
input that gets injected.

Users now specify: claude_args: '--json-schema {...}'
Instead of separate: json_schema: {...}

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

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: remove unused json-schema util and revert version

- Remove src/utils/json-schema.ts (no longer used after refactor)
- Revert Claude Code version from 2.0.45 back to 2.0.42

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-18 17:18:05 -08:00
GitHub Actions
e45f28fae7 chore: bump Claude Code version to 2.0.45 2025-11-18 16:50:24 +00:00
GitHub Actions
8c4e1e7eb1 chore: bump Claude Code version to 2.0.44 2025-11-18 04:50:59 +00:00
GitHub Actions
906bd89c74 chore: bump Claude Code version to 2.0.43 2025-11-18 00:29:32 +00:00
10 changed files with 320 additions and 523 deletions

View File

@@ -1,16 +1,10 @@
name: Test Structured Outputs (Optimized) name: Test Structured Outputs
# This workflow uses EXPLICIT prompts that tell Claude exactly what to return.
# This makes tests fast, deterministic, and focuses on testing OUR code, not Claude's reasoning.
#
# NOTE: Disabled until Agent SDK structured outputs feature is released
# The --json-schema flag is not yet available in public Claude Code releases
on: on:
# Disabled - uncomment when feature is released push:
# push: branches:
# branches: [main] - main
# pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -28,7 +22,6 @@ jobs:
id: test id: test
uses: ./base-action uses: ./base-action
with: with:
# EXPLICIT: Tell Claude exactly what to return - no reasoning needed
prompt: | prompt: |
Run this command: echo "test" Run this command: echo "test"
@@ -37,43 +30,41 @@ jobs:
- number_field: 42 - number_field: 42
- boolean_true: true - boolean_true: true
- boolean_false: false - boolean_false: false
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"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash" 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 - name: Verify outputs
run: | run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Test string pass-through # Test string pass-through
if [ "${{ steps.test.outputs.text_field }}" != "hello" ]; then TEXT_FIELD=$(echo "$OUTPUT" | jq -r '.text_field')
echo "❌ String: expected 'hello', got '${{ steps.test.outputs.text_field }}'" if [ "$TEXT_FIELD" != "hello" ]; then
echo "❌ String: expected 'hello', got '$TEXT_FIELD'"
exit 1 exit 1
fi fi
# Test number → string conversion # Test number → string conversion
if [ "${{ steps.test.outputs.number_field }}" != "42" ]; then NUMBER_FIELD=$(echo "$OUTPUT" | jq -r '.number_field')
echo "❌ Number: expected '42', got '${{ steps.test.outputs.number_field }}'" if [ "$NUMBER_FIELD" != "42" ]; then
echo "❌ Number: expected '42', got '$NUMBER_FIELD'"
exit 1 exit 1
fi fi
# Test boolean → "true" conversion # Test boolean → "true" conversion
if [ "${{ steps.test.outputs.boolean_true }}" != "true" ]; then BOOLEAN_TRUE=$(echo "$OUTPUT" | jq -r '.boolean_true')
echo "❌ Boolean true: expected 'true', got '${{ steps.test.outputs.boolean_true }}'" if [ "$BOOLEAN_TRUE" != "true" ]; then
echo "❌ Boolean true: expected 'true', got '$BOOLEAN_TRUE'"
exit 1 exit 1
fi fi
# Test boolean → "false" conversion # Test boolean → "false" conversion
if [ "${{ steps.test.outputs.boolean_false }}" != "false" ]; then BOOLEAN_FALSE=$(echo "$OUTPUT" | jq -r '.boolean_false')
echo "❌ Boolean false: expected 'false', got '${{ steps.test.outputs.boolean_false }}'" if [ "$BOOLEAN_FALSE" != "false" ]; then
echo "❌ Boolean false: expected 'false', got '$BOOLEAN_FALSE'"
exit 1 exit 1
fi fi
@@ -90,7 +81,6 @@ jobs:
id: test id: test
uses: ./base-action uses: ./base-action
with: with:
# EXPLICIT: No file reading, no analysis
prompt: | prompt: |
Run: echo "ready" Run: echo "ready"
@@ -98,46 +88,38 @@ jobs:
- items: ["apple", "banana", "cherry"] - items: ["apple", "banana", "cherry"]
- config: {"key": "value", "count": 3} - config: {"key": "value", "count": 3}
- empty_array: [] - empty_array: []
json_schema: |
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "string"}
},
"config": {"type": "object"},
"empty_array": {"type": "array"}
},
"required": ["items", "config", "empty_array"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash" 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 - name: Verify JSON stringification
run: | run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Arrays should be JSON stringified # Arrays should be JSON stringified
ITEMS='${{ steps.test.outputs.items }}' if ! echo "$OUTPUT" | jq -e '.items | length == 3' > /dev/null; then
if ! echo "$ITEMS" | jq -e '. | length == 3' > /dev/null; then echo "❌ Array not properly formatted"
echo "❌ Array not properly stringified: $ITEMS" echo "$OUTPUT" | jq '.items'
exit 1 exit 1
fi fi
# Objects should be JSON stringified # Objects should be JSON stringified
CONFIG='${{ steps.test.outputs.config }}' if ! echo "$OUTPUT" | jq -e '.config.key == "value"' > /dev/null; then
if ! echo "$CONFIG" | jq -e '.key == "value"' > /dev/null; then echo "❌ Object not properly formatted"
echo "❌ Object not properly stringified: $CONFIG" echo "$OUTPUT" | jq '.config'
exit 1 exit 1
fi fi
# Empty arrays should work # Empty arrays should work
EMPTY='${{ steps.test.outputs.empty_array }}' if ! echo "$OUTPUT" | jq -e '.empty_array | length == 0' > /dev/null; then
if ! echo "$EMPTY" | jq -e '. | length == 0' > /dev/null; then echo "❌ Empty array not properly formatted"
echo "❌ Empty array not properly stringified: $EMPTY" echo "$OUTPUT" | jq '.empty_array'
exit 1 exit 1
fi fi
echo "✅ All complex types JSON stringified correctly" echo "✅ All complex types handled correctly"
test-edge-cases: test-edge-cases:
name: Test Edge Cases name: Test Edge Cases
@@ -158,43 +140,41 @@ jobs:
- empty_string: "" - empty_string: ""
- negative: -5 - negative: -5
- decimal: 3.14 - decimal: 3.14
json_schema: |
{
"type": "object",
"properties": {
"zero": {"type": "number"},
"empty_string": {"type": "string"},
"negative": {"type": "number"},
"decimal": {"type": "number"}
},
"required": ["zero", "empty_string", "negative", "decimal"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash" 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 - name: Verify edge cases
run: | run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Zero should be "0", not empty or falsy # Zero should be "0", not empty or falsy
if [ "${{ steps.test.outputs.zero }}" != "0" ]; then ZERO=$(echo "$OUTPUT" | jq -r '.zero')
echo "❌ Zero: expected '0', got '${{ steps.test.outputs.zero }}'" if [ "$ZERO" != "0" ]; then
echo "❌ Zero: expected '0', got '$ZERO'"
exit 1 exit 1
fi fi
# Empty string should be empty (not "null" or missing) # Empty string should be empty (not "null" or missing)
if [ "${{ steps.test.outputs.empty_string }}" != "" ]; then EMPTY_STRING=$(echo "$OUTPUT" | jq -r '.empty_string')
echo "❌ Empty string: expected '', got '${{ steps.test.outputs.empty_string }}'" if [ "$EMPTY_STRING" != "" ]; then
echo "❌ Empty string: expected '', got '$EMPTY_STRING'"
exit 1 exit 1
fi fi
# Negative numbers should work # Negative numbers should work
if [ "${{ steps.test.outputs.negative }}" != "-5" ]; then NEGATIVE=$(echo "$OUTPUT" | jq -r '.negative')
echo "❌ Negative: expected '-5', got '${{ steps.test.outputs.negative }}'" if [ "$NEGATIVE" != "-5" ]; then
echo "❌ Negative: expected '-5', got '$NEGATIVE'"
exit 1 exit 1
fi fi
# Decimals should preserve precision # Decimals should preserve precision
if [ "${{ steps.test.outputs.decimal }}" != "3.14" ]; then DECIMAL=$(echo "$OUTPUT" | jq -r '.decimal')
echo "❌ Decimal: expected '3.14', got '${{ steps.test.outputs.decimal }}'" if [ "$DECIMAL" != "3.14" ]; then
echo "❌ Decimal: expected '3.14', got '$DECIMAL'"
exit 1 exit 1
fi fi
@@ -214,29 +194,27 @@ jobs:
prompt: | prompt: |
Run: echo "test" Run: echo "test"
Return EXACTLY: {test-result: "passed", item_count: 10} Return EXACTLY: {test-result: "passed", item_count: 10}
json_schema: |
{
"type": "object",
"properties": {
"test-result": {"type": "string"},
"item_count": {"type": "number"}
},
"required": ["test-result", "item_count"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash" 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 - name: Verify sanitized names work
run: | run: |
# Hyphens should be preserved (GitHub Actions allows them) # Parse the structured_output JSON
if [ "${{ steps.test.outputs.test-result }}" != "passed" ]; then OUTPUT='${{ steps.test.outputs.structured_output }}'
echo "❌ Hyphenated name failed"
# 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 exit 1
fi fi
# Underscores should work # Underscores should work
if [ "${{ steps.test.outputs.item_count }}" != "10" ]; then ITEM_COUNT=$(echo "$OUTPUT" | jq -r '.item_count')
echo "❌ Underscore name failed" if [ "$ITEM_COUNT" != "10" ]; then
echo "❌ Underscore name failed: expected '10', got '$ITEM_COUNT'"
exit 1 exit 1
fi fi
@@ -254,16 +232,10 @@ jobs:
uses: ./base-action uses: ./base-action
with: with:
prompt: "Run: echo 'complete'. Return: {done: true}" prompt: "Run: echo 'complete'. Return: {done: true}"
json_schema: |
{
"type": "object",
"properties": {
"done": {"type": "boolean"}
},
"required": ["done"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash" claude_args: |
--allowedTools Bash
--json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}'
- name: Verify execution file contains structured_output - name: Verify execution file contains structured_output
run: | run: |

View File

@@ -13,6 +13,7 @@ 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

View File

@@ -113,10 +113,6 @@ inputs:
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')" 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 required: false
default: "" default: ""
json_schema:
description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema, and the action will automatically set GitHub Action outputs for each field."
required: false
default: ""
outputs: outputs:
execution_file: execution_file:
@@ -128,6 +124,9 @@ 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 }}
runs: runs:
using: "composite" using: "composite"
@@ -178,7 +177,6 @@ runs:
TRACK_PROGRESS: ${{ inputs.track_progress }} TRACK_PROGRESS: ${{ inputs.track_progress }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
CLAUDE_ARGS: ${{ inputs.claude_args }} CLAUDE_ARGS: ${{ inputs.claude_args }}
JSON_SCHEMA: ${{ inputs.json_schema }}
ALL_INPUTS: ${{ toJson(inputs) }} ALL_INPUTS: ${{ toJson(inputs) }}
- name: Install Base Action Dependencies - name: Install Base Action Dependencies
@@ -194,7 +192,7 @@ runs:
# Install Claude Code if no custom executable is provided # Install Claude Code if no custom executable is provided
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..." echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.42 curl -fsSL https://claude.ai/install.sh | bash -s 2.0.45
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
@@ -233,7 +231,6 @@ runs:
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGINS: ${{ inputs.plugins }}
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }} INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
JSON_SCHEMA: ${{ inputs.json_schema }}
# Model configuration # Model configuration
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}

View File

@@ -67,10 +67,6 @@ inputs:
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')" 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 required: false
default: "" default: ""
json_schema:
description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema, and the action will automatically set GitHub Action outputs for each field (e.g., access via steps.id.outputs.field_name)"
required: false
default: ""
outputs: outputs:
conclusion: conclusion:
@@ -79,6 +75,9 @@ 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 }}
runs: runs:
using: "composite" using: "composite"
@@ -115,7 +114,7 @@ runs:
run: | run: |
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..." echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.42 curl -fsSL https://claude.ai/install.sh | bash -s 2.0.45
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH

View File

@@ -12,11 +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"];
type ExecutionMessage = {
type: string;
structured_output?: Record<string, unknown>;
};
/** /**
* Sanitizes JSON output to remove sensitive information when full output is disabled * 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 * Returns a safe summary message or null if the message should be completely suppressed
@@ -127,91 +122,54 @@ export function prepareRunConfig(
}; };
} }
/**
* Sanitizes output field names to meet GitHub Actions output naming requirements
* GitHub outputs must be alphanumeric, hyphen, or underscore only
*/
function sanitizeOutputName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
}
/**
* Converts values to string format for GitHub Actions outputs
* GitHub outputs must always be strings
*/
function convertToString(value: unknown): string {
switch (typeof value) {
case "string":
return value;
case "boolean":
case "number":
return String(value);
case "object":
return value === null ? "" : JSON.stringify(value);
case "undefined":
return "";
default:
// Handle Symbol, Function, etc.
return String(value);
}
}
/** /**
* Parses structured_output from execution file and sets GitHub Action outputs * Parses structured_output from execution file and sets GitHub Action outputs
* Only runs if json_schema was explicitly provided by the user * Only runs if --json-schema was explicitly provided in claude_args
* Exported for testing
*/ */
async function parseAndSetStructuredOutputs( export async function parseAndSetStructuredOutputs(
executionFile: string, executionFile: string,
): Promise<void> { ): Promise<void> {
try { try {
const content = await readFile(executionFile, "utf-8"); const content = await readFile(executionFile, "utf-8");
const messages = JSON.parse(content) as ExecutionMessage[]; const messages = JSON.parse(content) as {
type: string;
structured_output?: Record<string, unknown>;
}[];
const result = messages.find( // Search backwards - result is typically last or second-to-last message
const result = messages.findLast(
(m) => m.type === "result" && m.structured_output, (m) => m.type === "result" && m.structured_output,
); );
if (!result?.structured_output) { if (!result?.structured_output) {
const error = new Error( throw new Error(
"json_schema was provided but Claude did not return structured_output. " + `--json-schema was provided but Claude did not return structured_output.\n` +
"The schema may be invalid or Claude failed to call the StructuredOutput tool.", `Found ${messages.length} messages. Result exists: ${!!result}\n`,
); );
core.setFailed(error.message);
throw error;
} }
// Set GitHub Action output for each field // Set the complete structured output as a single JSON string
const entries = Object.entries(result.structured_output); // This works around GitHub Actions limitation that composite actions can't have dynamic outputs
core.info(`Setting ${entries.length} structured output(s)`); const structuredOutputJson = JSON.stringify(result.structured_output);
core.setOutput("structured_output", structuredOutputJson);
for (const [key, value] of entries) { core.info(
const sanitizedKey = sanitizeOutputName(key); `Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
if (!sanitizedKey) { );
core.warning(`Skipping invalid output key: "${key}"`);
continue;
}
const stringValue = convertToString(value);
// Truncate long values in logs for readability
const displayValue =
stringValue.length > 100
? `${stringValue.slice(0, 97)}...`
: stringValue;
core.setOutput(sanitizedKey, stringValue);
core.info(`${sanitizedKey}=${displayValue}`);
}
} catch (error) { } catch (error) {
const errorMsg = `Failed to parse structured outputs: ${error}`; if (error instanceof Error) {
core.setFailed(errorMsg); throw error; // Preserve original error and stack trace
throw new Error(errorMsg); }
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) {
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);
@@ -395,13 +353,23 @@ 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("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE); core.setOutput("execution_file", EXECUTION_FILE);
// Parse and set structured outputs only if user provided json_schema // Parse and set structured outputs only if user provided --json-schema in claude_args
if (process.env.JSON_SCHEMA) { if (hasJsonSchema) {
await parseAndSetStructuredOutputs(EXECUTION_FILE); 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");
} else { } else {
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");

View File

@@ -1,15 +1,11 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { describe, test, expect, afterEach } from "bun:test"; import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
import { writeFile, unlink } from "fs/promises"; import { writeFile, unlink } from "fs/promises";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { parseAndSetStructuredOutputs } from "../src/run-claude";
// Import the type for testing import * as core from "@actions/core";
type ExecutionMessage = {
type: string;
structured_output?: Record<string, unknown>;
};
// Mock execution file path // Mock execution file path
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json"); const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
@@ -19,9 +15,9 @@ async function createMockExecutionFile(
structuredOutput?: Record<string, unknown>, structuredOutput?: Record<string, unknown>,
includeResult: boolean = true, includeResult: boolean = true,
): Promise<void> { ): Promise<void> {
const messages: ExecutionMessage[] = [ const messages: any[] = [
{ type: "system", subtype: "init" } as any, { type: "system", subtype: "init" },
{ type: "turn", content: "test" } as any, { type: "turn", content: "test" },
]; ];
if (includeResult) { if (includeResult) {
@@ -30,14 +26,25 @@ async function createMockExecutionFile(
cost_usd: 0.01, cost_usd: 0.01,
duration_ms: 1000, duration_ms: 1000,
structured_output: structuredOutput, structured_output: structuredOutput,
} as any); });
} }
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
} }
describe("Structured Output - Pure Functions", () => { // Spy on core functions
let setOutputSpy: any;
let infoSpy: any;
beforeEach(() => {
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
infoSpy = spyOn(core, "info").mockImplementation(() => {});
});
describe("parseAndSetStructuredOutputs", () => {
afterEach(async () => { afterEach(async () => {
setOutputSpy?.mockRestore();
infoSpy?.mockRestore();
try { try {
await unlink(TEST_EXECUTION_FILE); await unlink(TEST_EXECUTION_FILE);
} catch { } catch {
@@ -45,297 +52,107 @@ describe("Structured Output - Pure Functions", () => {
} }
}); });
describe("sanitizeOutputName", () => { test("should set structured_output with valid data", async () => {
test("should keep valid characters", () => { await createMockExecutionFile({
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_"); is_flaky: true,
expect(sanitize("valid_name-123")).toBe("valid_name-123"); confidence: 0.85,
summary: "Test looks flaky",
}); });
test("should replace invalid characters with underscores", () => { await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
expect(sanitize("invalid@name!")).toBe("invalid_name_"); expect(setOutputSpy).toHaveBeenCalledWith(
expect(sanitize("has spaces")).toBe("has_spaces"); "structured_output",
expect(sanitize("has.dots")).toBe("has_dots"); '{"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 } },
}); });
test("should handle special characters", () => { await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
expect(sanitize("$field%name&")).toBe("_field_name_"); const callArgs = setOutputSpy.mock.calls[0];
expect(sanitize("field[0]")).toBe("field_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 } },
}); });
}); });
describe("convertToString", () => { test("should handle special characters in field names", async () => {
const convertToString = (value: unknown): string => { await createMockExecutionFile({
switch (typeof value) { "test-result": "passed",
case "string": "item.count": 10,
return value; "user@email": "test",
case "boolean":
case "number":
return String(value);
case "object":
return value === null ? "" : JSON.stringify(value);
default:
return JSON.stringify(value);
}
};
test("should keep strings as-is", () => {
expect(convertToString("hello")).toBe("hello");
expect(convertToString("")).toBe("");
}); });
test("should convert booleans to strings", () => { await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
expect(convertToString(true)).toBe("true");
expect(convertToString(false)).toBe("false");
});
test("should convert numbers to strings", () => { const callArgs = setOutputSpy.mock.calls[0];
expect(convertToString(42)).toBe("42"); const parsed = JSON.parse(callArgs[1]);
expect(convertToString(3.14)).toBe("3.14"); expect(parsed["test-result"]).toBe("passed");
expect(convertToString(0)).toBe("0"); expect(parsed["item.count"]).toBe(10);
}); expect(parsed["user@email"]).toBe("test");
test("should convert null to empty string", () => {
expect(convertToString(null)).toBe("");
});
test("should JSON stringify objects", () => {
expect(convertToString({ foo: "bar" })).toBe('{"foo":"bar"}');
});
test("should JSON stringify arrays", () => {
expect(convertToString([1, 2, 3])).toBe("[1,2,3]");
expect(convertToString(["a", "b"])).toBe('["a","b"]');
});
test("should handle nested structures", () => {
const nested = { items: [{ id: 1, name: "test" }] };
expect(convertToString(nested)).toBe(
'{"items":[{"id":1,"name":"test"}]}',
);
});
}); });
describe("parseAndSetStructuredOutputs integration", () => { test("should throw error when result exists but structured_output is undefined", async () => {
test("should parse and set simple structured outputs", async () => { const messages = [
await createMockExecutionFile({ { type: "system", subtype: "init" },
is_antonly: true, { type: "result", cost_usd: 0.01, duration_ms: 1000 },
confidence: 0.95, ];
risk: "low", await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
});
// In a real test, we'd import and call parseAndSetStructuredOutputs await expect(
// For now, we simulate the behavior parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
const content = await Bun.file(TEST_EXECUTION_FILE).text(); ).rejects.toThrow(
const messages = JSON.parse(content) as ExecutionMessage[]; "--json-schema was provided but Claude did not return structured_output",
const result = messages.find( );
(m) => m.type === "result" && m.structured_output,
);
expect(result?.structured_output).toEqual({
is_antonly: true,
confidence: 0.95,
risk: "low",
});
});
test("should handle array outputs", async () => {
await createMockExecutionFile({
affected_areas: ["auth", "database", "api"],
severity: "high",
});
const content = await Bun.file(TEST_EXECUTION_FILE).text();
const messages = JSON.parse(content) as ExecutionMessage[];
const result = messages.find(
(m) => m.type === "result" && m.structured_output,
);
expect(result?.structured_output?.affected_areas).toEqual([
"auth",
"database",
"api",
]);
});
test("should handle nested objects", async () => {
await createMockExecutionFile({
analysis: {
category: "test",
details: { count: 5, passed: true },
},
});
const content = await Bun.file(TEST_EXECUTION_FILE).text();
const messages = JSON.parse(content) as ExecutionMessage[];
const result = messages.find(
(m) => m.type === "result" && m.structured_output,
);
expect(result?.structured_output?.analysis).toEqual({
category: "test",
details: { count: 5, passed: true },
});
});
test("should handle missing structured_output", async () => {
await createMockExecutionFile(undefined, true);
const content = await Bun.file(TEST_EXECUTION_FILE).text();
const messages = JSON.parse(content) as ExecutionMessage[];
const result = messages.find(
(m) => m.type === "result" && m.structured_output,
);
expect(result).toBeUndefined();
});
test("should handle empty structured_output", async () => {
await createMockExecutionFile({});
const content = await Bun.file(TEST_EXECUTION_FILE).text();
const messages = JSON.parse(content) as ExecutionMessage[];
const result = messages.find(
(m) => m.type === "result" && m.structured_output,
);
expect(result?.structured_output).toEqual({});
});
test("should handle all supported types", async () => {
await createMockExecutionFile({
string_field: "hello",
number_field: 42,
boolean_field: true,
null_field: null,
array_field: [1, 2, 3],
object_field: { nested: "value" },
});
const content = await Bun.file(TEST_EXECUTION_FILE).text();
const messages = JSON.parse(content) as ExecutionMessage[];
const result = messages.find(
(m) => m.type === "result" && m.structured_output,
);
expect(result?.structured_output).toMatchObject({
string_field: "hello",
number_field: 42,
boolean_field: true,
null_field: null,
array_field: [1, 2, 3],
object_field: { nested: "value" },
});
});
}); });
describe("output naming with prefix", () => { test("should throw error when no result message exists", async () => {
test("should apply prefix correctly", () => { const messages = [
const prefix = "CLAUDE_"; { type: "system", subtype: "init" },
const key = "is_antonly"; { type: "turn", content: "test" },
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_"); ];
const outputName = prefix + sanitizedKey; await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
expect(outputName).toBe("CLAUDE_is_antonly"); await expect(
}); parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
).rejects.toThrow(
test("should handle empty prefix", () => { "--json-schema was provided but Claude did not return structured_output",
const prefix = ""; );
const key = "result";
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
const outputName = prefix + sanitizedKey;
expect(outputName).toBe("result");
});
test("should sanitize and prefix invalid keys", () => {
const prefix = "OUT_";
const key = "invalid@key!";
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
const outputName = prefix + sanitizedKey;
expect(outputName).toBe("OUT_invalid_key_");
});
}); });
describe("error scenarios", () => { test("should throw error with malformed JSON", async () => {
test("should handle malformed JSON", async () => { await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
await writeFile(TEST_EXECUTION_FILE, "invalid json {");
let error: Error | undefined; await expect(
try { parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
const content = await Bun.file(TEST_EXECUTION_FILE).text(); ).rejects.toThrow();
JSON.parse(content);
} catch (e) {
error = e as Error;
}
expect(error).toBeDefined();
expect(error?.message).toContain("JSON");
});
test("should handle empty execution file", async () => {
await writeFile(TEST_EXECUTION_FILE, "[]");
const content = await Bun.file(TEST_EXECUTION_FILE).text();
const messages = JSON.parse(content) as ExecutionMessage[];
const result = messages.find(
(m) => m.type === "result" && m.structured_output,
);
expect(result).toBeUndefined();
});
test("should handle missing result message", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "turn", content: "test" },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
const content = await Bun.file(TEST_EXECUTION_FILE).text();
const parsed = JSON.parse(content) as ExecutionMessage[];
const result = parsed.find(
(m) => m.type === "result" && m.structured_output,
);
expect(result).toBeUndefined();
});
}); });
describe("value truncation in logs", () => { test("should throw error when file does not exist", async () => {
test("should truncate long string values for display", () => { await expect(
const longValue = "a".repeat(150); parseAndSetStructuredOutputs("/nonexistent/file.json"),
const displayValue = ).rejects.toThrow();
longValue.length > 100 ? `${longValue.slice(0, 97)}...` : longValue; });
expect(displayValue).toBe("a".repeat(97) + "..."); test("should handle empty structured_output object", async () => {
expect(displayValue.length).toBe(100); await createMockExecutionFile({});
});
test("should not truncate short values", () => { await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
const shortValue = "short";
const displayValue =
shortValue.length > 100 ? `${shortValue.slice(0, 97)}...` : shortValue;
expect(displayValue).toBe("short"); expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
}); expect(infoSpy).toHaveBeenCalledWith(
"Set structured_output with 0 field(s)",
test("should truncate exactly 100 character values", () => { );
const value = "a".repeat(100);
const displayValue =
value.length > 100 ? `${value.slice(0, 97)}...` : value;
expect(displayValue).toBe(value);
});
test("should truncate 101 character values", () => {
const value = "a".repeat(101);
const displayValue =
value.length > 100 ? `${value.slice(0, 97)}...` : value;
expect(displayValue).toBe("a".repeat(97) + "...");
});
}); });
}); });

View File

@@ -185,6 +185,74 @@ 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.

View File

@@ -43,38 +43,23 @@ jobs:
- is_flaky: true if likely flaky, false if real bug - is_flaky: true if likely flaky, false if real bug
- confidence: number 0-1 indicating confidence level - confidence: number 0-1 indicating confidence level
- summary: brief one-sentence explanation - summary: brief one-sentence explanation
json_schema: | 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"]}'
"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) # Auto-retry only if flaky AND high confidence (>= 0.7)
- name: Retry flaky tests - name: Retry flaky tests
if: | if: |
steps.detect.outputs.is_flaky == 'true' && fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
steps.detect.outputs.confidence >= '0.7' fromJSON(steps.detect.outputs.structured_output).confidence >= 0.7
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
echo "🔄 Flaky test detected (confidence: ${{ steps.detect.outputs.confidence }})" OUTPUT='${{ steps.detect.outputs.structured_output }}'
echo "Summary: ${{ steps.detect.outputs.summary }}" CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
echo "🔄 Flaky test detected (confidence: $CONFIDENCE)"
echo "Summary: $SUMMARY"
echo "" echo ""
echo "Triggering automatic retry..." echo "Triggering automatic retry..."
@@ -84,10 +69,13 @@ jobs:
# Low confidence flaky detection - skip retry # Low confidence flaky detection - skip retry
- name: Low confidence detection - name: Low confidence detection
if: | if: |
steps.detect.outputs.is_flaky == 'true' && fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
steps.detect.outputs.confidence < '0.7' fromJSON(steps.detect.outputs.structured_output).confidence < 0.7
run: | run: |
echo "⚠️ Possible flaky test but confidence too low (${{ steps.detect.outputs.confidence }})" 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" echo "Not retrying automatically - manual review recommended"
# Comment on PR if this was a PR build # Comment on PR if this was a PR build
@@ -96,16 +84,29 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | 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') pr_number=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" --json number --jq '.[0].number')
if [ -n "$pr_number" ]; then 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 gh pr comment "$pr_number" --body "$(cat <<EOF
## ${{ steps.detect.outputs.is_flaky == 'true' && '🔄 Flaky Test Detected' || '❌ Test Failure' }} ## $TITLE
**Analysis**: ${{ steps.detect.outputs.summary }} **Analysis**: $SUMMARY
**Confidence**: ${{ steps.detect.outputs.confidence }} **Confidence**: $CONFIDENCE
${{ steps.detect.outputs.is_flaky == 'true' && '✅ Automatically retrying the workflow' || '⚠️ This appears to be a real bug - manual intervention needed' }} $ACTION
[View workflow run](${{ github.event.workflow_run.html_url }}) [View workflow run](${{ github.event.workflow_run.html_url }})
EOF EOF

View File

@@ -149,19 +149,6 @@ export const agentMode: Mode = {
claudeArgs = `--mcp-config '${escapedOurConfig}'`; claudeArgs = `--mcp-config '${escapedOurConfig}'`;
} }
// Add JSON schema if provided
const jsonSchema = process.env.JSON_SCHEMA || "";
if (jsonSchema) {
// Validate it's valid JSON
try {
JSON.parse(jsonSchema);
} catch (e) {
throw new Error(`Invalid JSON schema provided: ${e}`);
}
const escapedSchema = jsonSchema.replace(/'/g, "'\\''");
claudeArgs += ` --json-schema '${escapedSchema}'`;
}
// Append user's claude_args (which may have more --mcp-config flags) // Append user's claude_args (which may have more --mcp-config flags)
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim(); claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();

View File

@@ -177,19 +177,6 @@ export const tagMode: Mode = {
// Add required tools for tag mode // Add required tools for tag mode
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`; claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
// Add JSON schema if provided
const jsonSchema = process.env.JSON_SCHEMA || "";
if (jsonSchema) {
// Validate it's valid JSON
try {
JSON.parse(jsonSchema);
} catch (e) {
throw new Error(`Invalid JSON schema provided: ${e}`);
}
const escapedSchema = jsonSchema.replace(/'/g, "'\\''");
claudeArgs += ` --json-schema '${escapedSchema}'`;
}
// Append user's claude_args (which may have more --mcp-config flags) // Append user's claude_args (which may have more --mcp-config flags)
if (userClaudeArgs) { if (userClaudeArgs) {
claudeArgs += ` ${userClaudeArgs}`; claudeArgs += ` ${userClaudeArgs}`;