Skip to content

Commit 6780530

Browse files
authored
Merge pull request #76 from syou6162/pre_commit
feat: commandアクションにoutput_format: text設定を追加
2 parents be29a3d + ad79c0a commit 6780530

File tree

7 files changed

+411
-0
lines changed

7 files changed

+411
-0
lines changed

CLAUDE.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,17 @@ SessionStart hooks **always exit with code 0**, even when:
297297

298298
Errors are logged to stderr as warnings, but cchook continues to output JSON and exits successfully. This ensures Claude Code always receives a response.
299299

300+
**Plain Text Output** (`output_format: text`):
301+
For commands that output plain text (not JSON), use `output_format: text` to treat the stdout as `additionalContext`:
302+
```yaml
303+
SessionStart:
304+
- actions:
305+
- type: command
306+
command: "get-project-info.sh" # Returns plain text, not JSON
307+
output_format: text # stdout treated as additionalContext
308+
```
309+
- Without `output_format: text`, non-JSON stdout causes `continue: false` (fail-safe)
310+
300311
**Example**:
301312
```yaml
302313
SessionStart:
@@ -433,6 +444,19 @@ PreToolUse hooks **always exit with code 0**. The `permissionDecision` field con
433444

434445
Errors are logged to stderr as warnings, but cchook continues to output JSON and exits successfully. On errors, `permissionDecision` defaults to `"deny"` for safety.
435446

447+
**Plain Text Output** (`output_format: text`):
448+
For commands that output plain text (not JSON), use `output_format: text` to treat the stdout as `permissionDecisionReason` with `permissionDecision: "allow"`:
449+
```yaml
450+
PreToolUse:
451+
- matcher: "Bash"
452+
actions:
453+
- type: command
454+
command: "shellcheck {.tool_input.command}"
455+
output_format: text # stdout treated as permissionDecisionReason, permissionDecision set to "allow"
456+
```
457+
- Non-zero exit code still causes fail-safe `permissionDecision: "deny"` behavior
458+
- Without `output_format: text`, non-JSON stdout causes `permissionDecision: "deny"` (fail-safe)
459+
436460
**Example**:
437461
```yaml
438462
PreToolUse:
@@ -641,6 +665,22 @@ After JSON migration:
641665
- Use `decision` field instead: omit for allow, `"block"` to prompt Claude
642666
- A stderr warning is emitted if `exit_status` is set (migration reminder)
643667

668+
**Plain Text Output** (`output_format: text`):
669+
For commands that output plain text (not JSON), use `output_format: text` to treat the stdout as `additionalContext`:
670+
```yaml
671+
PostToolUse:
672+
- matcher: "Write|Edit|MultiEdit"
673+
conditions:
674+
- type: file_extension
675+
value: ".go"
676+
actions:
677+
- type: command
678+
command: "gofmt -w {.tool_input.file_path}"
679+
output_format: text # stdout treated as additionalContext, not parsed as JSON
680+
```
681+
- Non-zero exit code still causes fail-safe `decision: "block"` behavior
682+
- Without `output_format: text`, non-JSON stdout causes `decision: "block"` (fail-safe)
683+
644684
**Example**:
645685
```yaml
646686
PostToolUse:
@@ -873,6 +913,17 @@ SessionEnd supports the `reason_is` condition to match session end reasons:
873913
- `"prompt_input_exit"`: User exited via prompt input
874914
- `"other"`: Other reasons
875915

916+
**Plain Text Output** (`output_format: text`):
917+
For commands that output plain text (not JSON), use `output_format: text` to treat the stdout as `systemMessage`:
918+
```yaml
919+
SessionEnd:
920+
- actions:
921+
- type: command
922+
command: "cleanup-session.sh" # Returns plain text, not JSON
923+
output_format: text # stdout treated as systemMessage
924+
```
925+
- Without `output_format: text`, non-JSON stdout causes systemMessage set to error message
926+
876927
**Example**:
877928
```yaml
878929
SessionEnd:

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ PostToolUse:
149149
actions:
150150
- type: command
151151
command: "gofmt -w {.tool_input.file_path}"
152+
output_format: text # gofmt outputs plain text, not JSON
152153

153154
# Guide users to use better alternatives
154155
PreToolUse:
@@ -241,6 +242,7 @@ PostToolUse:
241242
actions:
242243
- type: command
243244
command: "gofmt -w {.tool_input.file_path}"
245+
output_format: text # gofmt outputs plain text, not JSON
244246
245247
- matcher: "Write|Edit"
246248
conditions:
@@ -249,6 +251,7 @@ PostToolUse:
249251
actions:
250252
- type: command
251253
command: "black {.tool_input.file_path}"
254+
output_format: text # black outputs plain text, not JSON
252255
```
253256

254257
Run pre-commit hooks automatically:
@@ -262,6 +265,7 @@ PostToolUse:
262265
actions:
263266
- type: command
264267
command: "pre-commit run --files {.tool_input.file_path}"
268+
output_format: text # pre-commit outputs plain text, not JSON
265269
```
266270

267271
Warn about sensitive file modifications:
@@ -631,6 +635,12 @@ All conditions return proper error messages for unknown condition types, ensurin
631635
- Solves issues with special characters (quotes, backslashes, newlines) in data
632636
- Safer than shell string interpolation for complex data
633637
- Example: `jq -r .tool_input.content` to extract content from JSON via stdin
638+
- `output_format: text` (optional)
639+
- Treat command's stdout as plain text instead of JSON
640+
- Without this, non-JSON output causes fail-safe block/deny behavior
641+
- Use for standard CLI tools like `gofmt`, `black`, `pre-commit run` that output plain text
642+
- Non-zero exit code still triggers fail-safe behavior regardless of this setting
643+
- Supported events: PreToolUse, PostToolUse, SessionStart, SessionEnd
634644
- `output`
635645
- Print message
636646
- Default `exit_status`:
@@ -702,8 +712,10 @@ PostToolUse:
702712
actions:
703713
- type: command
704714
command: "ruff format {.tool_input.file_path}"
715+
output_format: text
705716
- type: command
706717
command: "ruff check --fix {.tool_input.file_path}"
718+
output_format: text
707719
```
708720

709721
### Multi-Step Workflows
@@ -717,8 +729,10 @@ PostToolUse:
717729
actions:
718730
- type: command
719731
command: "gofmt -w {.tool_input.file_path}"
732+
output_format: text # gofmt outputs plain text, not JSON
720733
- type: command
721734
command: "go vet {.tool_input.file_path}"
735+
output_format: text # go vet outputs plain text, not JSON
722736
- type: output
723737
message: "✅ Go file formatted and vetted: {.tool_input.file_path}"
724738
```

executor.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,15 @@ func (e *ActionExecutor) ExecuteSessionStartAction(action Action, input *Session
698698
}, nil
699699
}
700700

701+
// output_format: text - treat command output as plain text (no JSON parsing)
702+
if action.OutputFormat != nil && *action.OutputFormat == "text" {
703+
return &ActionOutput{
704+
Continue: true,
705+
HookEventName: "SessionStart",
706+
AdditionalContext: strings.TrimSpace(stdout),
707+
}, nil
708+
}
709+
701710
// Parse JSON output
702711
var cmdOutput SessionStartOutput
703712
if err := json.Unmarshal([]byte(stdout), &cmdOutput); err != nil {
@@ -966,6 +975,14 @@ func (e *ActionExecutor) ExecuteSessionEndAction(action Action, input *SessionEn
966975
}, nil
967976
}
968977

978+
// output_format: text - treat command output as plain text (no JSON parsing)
979+
if action.OutputFormat != nil && *action.OutputFormat == "text" {
980+
return &ActionOutput{
981+
Continue: true,
982+
SystemMessage: strings.TrimSpace(stdout),
983+
}, nil
984+
}
985+
969986
// Parse JSON output (SessionEndOutput has Common JSON Fields only)
970987
var cmdOutput SessionEndOutput
971988
if err := json.Unmarshal([]byte(stdout), &cmdOutput); err != nil {

executor_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2841,3 +2841,148 @@ func TestExecuteSubagentStartAction_TypeCommand(t *testing.T) {
28412841
})
28422842
}
28432843
}
2844+
2845+
func TestExecuteSessionStartAction_OutputFormatText(t *testing.T) {
2846+
outputFormatText := "text"
2847+
tests := []struct {
2848+
name string
2849+
stdout string
2850+
stderr string
2851+
exitCode int
2852+
wantContinue bool
2853+
wantHookEventName string
2854+
wantAdditionalCtx string
2855+
}{
2856+
{
2857+
name: "output_format: text + text output -> additionalContext with HookEventName set",
2858+
stdout: "Project: myapp\nBranch: main\n",
2859+
exitCode: 0,
2860+
wantContinue: true,
2861+
wantHookEventName: "SessionStart",
2862+
wantAdditionalCtx: "Project: myapp\nBranch: main",
2863+
},
2864+
{
2865+
name: "output_format: text + empty output -> continue=true, no additionalContext",
2866+
stdout: "",
2867+
exitCode: 0,
2868+
wantContinue: true,
2869+
wantHookEventName: "SessionStart",
2870+
wantAdditionalCtx: "",
2871+
},
2872+
{
2873+
name: "output_format: text + non-zero exit code -> existing fail-safe behavior",
2874+
stdout: "",
2875+
stderr: "command error",
2876+
exitCode: 1,
2877+
wantContinue: false,
2878+
wantHookEventName: "", // non-zero exit uses existing behavior (HookEventName not set)
2879+
},
2880+
}
2881+
2882+
for _, tt := range tests {
2883+
t.Run(tt.name, func(t *testing.T) {
2884+
action := Action{
2885+
Type: "command",
2886+
Command: "get-project-info.sh",
2887+
OutputFormat: &outputFormatText,
2888+
}
2889+
input := &SessionStartInput{}
2890+
runner := &stubRunnerWithOutput{
2891+
stdout: tt.stdout,
2892+
stderr: tt.stderr,
2893+
exitCode: tt.exitCode,
2894+
}
2895+
executor := NewActionExecutor(runner)
2896+
output, err := executor.ExecuteSessionStartAction(action, input, map[string]any{})
2897+
2898+
if err != nil {
2899+
t.Fatalf("Expected no error, got: %v", err)
2900+
}
2901+
2902+
if output == nil {
2903+
t.Fatal("Expected non-nil output, got nil")
2904+
}
2905+
2906+
if output.Continue != tt.wantContinue {
2907+
t.Errorf("Continue = %v, want %v", output.Continue, tt.wantContinue)
2908+
}
2909+
2910+
if output.HookEventName != tt.wantHookEventName {
2911+
t.Errorf("HookEventName = %q, want %q", output.HookEventName, tt.wantHookEventName)
2912+
}
2913+
2914+
if output.AdditionalContext != tt.wantAdditionalCtx {
2915+
t.Errorf("AdditionalContext = %q, want %q", output.AdditionalContext, tt.wantAdditionalCtx)
2916+
}
2917+
})
2918+
}
2919+
}
2920+
2921+
func TestExecuteSessionEndAction_OutputFormatText(t *testing.T) {
2922+
outputFormatText := "text"
2923+
tests := []struct {
2924+
name string
2925+
stdout string
2926+
stderr string
2927+
exitCode int
2928+
wantContinue bool
2929+
wantSystemMessage string
2930+
}{
2931+
{
2932+
name: "output_format: text + text output -> systemMessage with continue=true",
2933+
stdout: "Cleanup completed.\nTemp files removed.\n",
2934+
exitCode: 0,
2935+
wantContinue: true,
2936+
wantSystemMessage: "Cleanup completed.\nTemp files removed.",
2937+
},
2938+
{
2939+
name: "output_format: text + empty output -> continue=true, no systemMessage",
2940+
stdout: "",
2941+
exitCode: 0,
2942+
wantContinue: true,
2943+
wantSystemMessage: "",
2944+
},
2945+
{
2946+
name: "output_format: text + non-zero exit code -> existing fail-safe behavior",
2947+
stdout: "",
2948+
stderr: "command error",
2949+
exitCode: 1,
2950+
wantContinue: true,
2951+
wantSystemMessage: "Command failed with exit code 1: command error",
2952+
},
2953+
}
2954+
2955+
for _, tt := range tests {
2956+
t.Run(tt.name, func(t *testing.T) {
2957+
action := Action{
2958+
Type: "command",
2959+
Command: "cleanup.sh",
2960+
OutputFormat: &outputFormatText,
2961+
}
2962+
input := &SessionEndInput{}
2963+
runner := &stubRunnerWithOutput{
2964+
stdout: tt.stdout,
2965+
stderr: tt.stderr,
2966+
exitCode: tt.exitCode,
2967+
}
2968+
executor := NewActionExecutor(runner)
2969+
output, err := executor.ExecuteSessionEndAction(action, input, map[string]any{})
2970+
2971+
if err != nil {
2972+
t.Fatalf("Expected no error, got: %v", err)
2973+
}
2974+
2975+
if output == nil {
2976+
t.Fatal("Expected non-nil output, got nil")
2977+
}
2978+
2979+
if output.Continue != tt.wantContinue {
2980+
t.Errorf("Continue = %v, want %v", output.Continue, tt.wantContinue)
2981+
}
2982+
2983+
if output.SystemMessage != tt.wantSystemMessage {
2984+
t.Errorf("SystemMessage = %q, want %q", output.SystemMessage, tt.wantSystemMessage)
2985+
}
2986+
})
2987+
}
2988+
}

executor_tool_permission.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ func (e *ActionExecutor) ExecutePreToolUseAction(action Action, input *PreToolUs
3737
return nil, nil
3838
}
3939

40+
// output_format: text - treat command output as plain text (no JSON parsing)
41+
if action.OutputFormat != nil && *action.OutputFormat == "text" {
42+
return &ActionOutput{
43+
Continue: true,
44+
PermissionDecision: "allow",
45+
PermissionDecisionReason: strings.TrimSpace(stdout),
46+
HookEventName: "PreToolUse",
47+
}, nil
48+
}
49+
4050
// Parse JSON output
4151
var cmdOutput PreToolUseOutput
4252
if err := json.Unmarshal([]byte(stdout), &cmdOutput); err != nil {
@@ -244,6 +254,16 @@ func (e *ActionExecutor) ExecutePostToolUseAction(action Action, input *PostTool
244254
}, nil
245255
}
246256

257+
// output_format: text - treat command output as plain text (no JSON parsing)
258+
if action.OutputFormat != nil && *action.OutputFormat == "text" {
259+
return &ActionOutput{
260+
Continue: true,
261+
Decision: "", // Allow tool result
262+
AdditionalContext: strings.TrimSpace(stdout),
263+
HookEventName: "PostToolUse",
264+
}, nil
265+
}
266+
247267
// Parse JSON output (PostToolUseOutput with hookSpecificOutput)
248268
var cmdOutput PostToolUseOutput
249269
if err := json.Unmarshal([]byte(stdout), &cmdOutput); err != nil {

0 commit comments

Comments
 (0)