Skip to content

Commit 426a5d3

Browse files
committed
docs: add custom executor guide and update architecture references
1 parent 502f676 commit 426a5d3

File tree

5 files changed

+202
-9
lines changed

5 files changed

+202
-9
lines changed

CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Claude Chat (mobile/web)
4949
→ Herald Server (Go binary, port 8420)
5050
→ MCP Handler (/mcp)
5151
→ Task Manager (goroutines)
52-
Claude Code Executor (os/exec → claude -p --output-format stream-json)
52+
→ Executor Registry (pluggable backends, default: Claude Code)
5353
→ SQLite (persistence)
5454
→ MCP Notifications (server push via SSE)
5555
@@ -90,10 +90,13 @@ herald/
9090
│ │ ├── ratelimit.go # Rate limiting middleware
9191
│ │ └── security.go # Security headers middleware
9292
│ ├── executor/
93-
│ │ ├── executor.go # Interface Executor
94-
│ │ ├── claude.go # Claude Code executor (os/exec)
95-
│ │ ├── stream.go # Parsing stream-json output
96-
│ │ └── prompt.go # Prompt file management (work_dir)
93+
│ │ ├── executor.go # Interface Executor + Capabilities
94+
│ │ ├── registry.go # Pluggable executor registry (Register/Get/Available)
95+
│ │ ├── kill.go # GracefulKill (shared POSIX utility)
96+
│ │ ├── prompt.go # Prompt file management (work_dir)
97+
│ │ └── claude/
98+
│ │ ├── claude.go # Claude Code executor (os/exec, auto-registers as "claude-code")
99+
│ │ └── stream.go # Parsing stream-json output
97100
│ ├── task/
98101
│ │ ├── manager.go # Task lifecycle management
99102
│ │ └── task.go # Task struct et états

CONTRIBUTING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ Scope is encouraged: `feat(mcp):`, `fix(auth):`, `docs(readme):`
5555
5. Commit with conventional commit messages
5656
6. Open a PR against `main`
5757

58+
## Custom Executors
59+
60+
Herald's executor architecture is pluggable. You can implement adapters for other CLI tools (Codex, Gemini CLI, Aider, etc.) by implementing the `executor.Executor` interface and registering via `init()`. See [`docs/guide/custom-executor.md`](docs/guide/custom-executor.md) for a complete guide.
61+
5862
## Code Style
5963

6064
- Go 1.26 standard formatting (`gofmt`)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ Claude Chat (mobile/web)
368368
├── MCP Handler (/mcp)
369369
├── OAuth 2.1 Server (PKCE, token rotation)
370370
├── Task Manager (goroutine pool, priority queue)
371-
├── Claude Code Executor (os/exec, stream-json parsing)
371+
├── Executor Registry (pluggable backends, default: Claude Code)
372372
├── SQLite (persistence)
373373
└── MCP Notifications (server push via SSE)
374374
```

docs/architecture/overview.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Claude Chat (mobile/web)
1010
├── MCP Handler (/mcp)
1111
├── OAuth 2.1 Server (PKCE, token rotation)
1212
├── Task Manager (goroutine pool, priority queue)
13-
├── Claude Code Executor (os/exec, stream-json parsing)
13+
├── Executor Registry (pluggable backends, default: Claude Code)
1414
├── SQLite (persistence)
1515
└── MCP Notifications (server push via SSE)
1616
@@ -63,7 +63,7 @@ Each `internal/` package is autonomous and communicates with others through inte
6363
|---|---|---|
6464
| **MCP Server** | `internal/mcp` | Handles MCP Streamable HTTP requests, registers tools |
6565
| **Task Manager** | `internal/task` | Task lifecycle, priority queue, goroutine pool |
66-
| **Executor** | `internal/executor` | Spawns Claude Code via `os/exec`, parses stream-json output |
66+
| **Executor** | `internal/executor` | Pluggable executor registry. Default: Claude Code (`internal/executor/claude`) |
6767
| **Store** | `internal/store` | SQLite persistence — tasks, tokens, audit log |
6868
| **Auth** | `internal/auth` | OAuth 2.1 server with PKCE, JWT tokens, token rotation |
6969
| **Notify** | `internal/notify` | MCP push notifications (server-initiated via SSE) |
@@ -81,9 +81,10 @@ type Store interface {
8181
ListTasks(f TaskFilter) ([]TaskRecord, error)
8282
}
8383

84-
// executor.Executor — task execution
84+
// executor.Executor — task execution (pluggable via registry)
8585
type Executor interface {
8686
Execute(ctx context.Context, req Request, onProgress ProgressFunc) (*Result, error)
87+
Capabilities() Capabilities
8788
}
8889

8990
// notify.Notifier — notification delivery

docs/guide/custom-executor.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Custom Executor Guide
2+
3+
Herald's executor architecture is pluggable. While the default executor is `claude-code` (Claude Code CLI), you can implement adapters for any CLI tool — Codex, Gemini CLI, Aider, or your own wrapper.
4+
5+
## Architecture
6+
7+
```
8+
executor/
9+
├── executor.go # Interface + Capabilities + Registry
10+
├── registry.go # Register/Get/Available
11+
├── kill.go # GracefulKill (shared POSIX utility)
12+
└── claude/
13+
├── claude.go # Claude Code implementation (init() auto-registers as "claude-code")
14+
└── stream.go # stream-json output parsing
15+
```
16+
17+
The registry pattern uses Go `init()` functions for zero-config registration. Each executor lives in its own sub-package under `internal/executor/`.
18+
19+
## Implementing an Executor
20+
21+
### 1. Create a sub-package
22+
23+
```
24+
internal/executor/mybackend/
25+
└── mybackend.go
26+
```
27+
28+
### 2. Implement the interface
29+
30+
```go
31+
package mybackend
32+
33+
import (
34+
"context"
35+
36+
"github.com/btouchard/herald/internal/executor"
37+
)
38+
39+
const name = "my-backend"
40+
41+
func init() {
42+
executor.Register(name, factory)
43+
}
44+
45+
func factory(cfg map[string]any) (executor.Executor, error) {
46+
binaryPath, _ := cfg["binary_path"].(string)
47+
if binaryPath == "" {
48+
binaryPath = "my-cli" // default binary name
49+
}
50+
return &Executor{binaryPath: binaryPath}, nil
51+
}
52+
53+
type Executor struct {
54+
binaryPath string
55+
}
56+
57+
func (e *Executor) Capabilities() executor.Capabilities {
58+
return executor.Capabilities{
59+
Name: name,
60+
Version: "0.1.0",
61+
SupportsSession: false, // set true if your CLI supports session resumption
62+
SupportsModel: false, // set true if your CLI supports model selection
63+
SupportsToolList: false, // set true if your CLI supports tool restrictions
64+
SupportsDryRun: false, // set true if your CLI supports dry-run/plan mode
65+
SupportsStreaming: true, // set true if you stream progress events
66+
}
67+
}
68+
69+
func (e *Executor) Execute(ctx context.Context, req executor.Request, onProgress executor.ProgressFunc) (*executor.Result, error) {
70+
// Build and run your CLI command here.
71+
// Use req.Prompt, req.ProjectPath, req.TimeoutMinutes, etc.
72+
// Call onProgress("progress", "message") to report status.
73+
// Return an executor.Result with output, cost, turns, etc.
74+
75+
return &executor.Result{
76+
Output: "task completed",
77+
ExitCode: 0,
78+
}, nil
79+
}
80+
```
81+
82+
### 3. Register via blank import
83+
84+
In `cmd/herald/main.go`, add a blank import so the `init()` function runs:
85+
86+
```go
87+
import (
88+
_ "github.com/btouchard/herald/internal/executor/claude"
89+
_ "github.com/btouchard/herald/internal/executor/mybackend"
90+
)
91+
```
92+
93+
### 4. Select via config
94+
95+
In `herald.yaml`:
96+
97+
```yaml
98+
execution:
99+
executor: "my-backend"
100+
```
101+
102+
If omitted, defaults to `"claude-code"`.
103+
104+
## Capabilities
105+
106+
The `Capabilities` struct tells MCP handlers what your executor supports. When a user requests a feature that your executor doesn't support (e.g., session resumption), Herald shows a warning in the response — not an error.
107+
108+
| Field | Description |
109+
|---|---|
110+
| `SupportsSession` | Can resume previous conversations via `session_id` |
111+
| `SupportsModel` | Can override the model per task |
112+
| `SupportsToolList` | Can restrict which tools the CLI uses |
113+
| `SupportsDryRun` | Can run in plan-only mode without making changes |
114+
| `SupportsStreaming` | Emits progress events during execution |
115+
| `Name` | Display name shown in MCP responses |
116+
| `Version` | Executor version string |
117+
118+
## Request and Result
119+
120+
### Request fields
121+
122+
| Field | Description | Capability required |
123+
|---|---|---|
124+
| `TaskID` | Unique task identifier | Always available |
125+
| `Prompt` | User prompt text | Always available |
126+
| `ProjectPath` | Absolute path to project directory | Always available |
127+
| `TimeoutMinutes` | Maximum execution time | Always available |
128+
| `Env` | Environment variables to set | Always available |
129+
| `SessionID` | Session to resume | `SupportsSession` |
130+
| `Model` | Model override | `SupportsModel` |
131+
| `AllowedTools` | Tool restrictions | `SupportsToolList` |
132+
| `DryRun` | Plan-only mode | `SupportsDryRun` |
133+
134+
Fields that require capabilities your executor doesn't support are silently ignored. Herald warns the user in the MCP response.
135+
136+
### Result fields
137+
138+
| Field | Description |
139+
|---|---|
140+
| `SessionID` | Session ID for future resumption (if supported) |
141+
| `Output` | Full text output from the CLI |
142+
| `CostUSD` | Estimated cost in USD (0 if not available) |
143+
| `Turns` | Number of conversation turns |
144+
| `Duration` | Execution duration |
145+
| `ExitCode` | Process exit code (0 = success) |
146+
147+
## Progress reporting
148+
149+
Call `onProgress` during execution to update task status in real-time:
150+
151+
```go
152+
onProgress("progress", "Reading project files...")
153+
onProgress("progress", "Generating code changes...")
154+
onProgress("result", "Task completed successfully")
155+
```
156+
157+
Progress messages appear in `check_task` responses and MCP push notifications.
158+
159+
## Factory configuration
160+
161+
The factory receives a `map[string]any` built from Herald's config. The Claude Code executor uses these keys:
162+
163+
- `claude_path` — path to the CLI binary
164+
- `work_dir` — working directory for task files
165+
- `env` — environment variables map
166+
167+
You can define your own keys. They are passed through from the execution config.
168+
169+
## Testing
170+
171+
Test your executor with mock commands. See `internal/executor/claude/claude_test.go` for patterns:
172+
173+
```go
174+
func TestMyExecutor_Execute_Success(t *testing.T) {
175+
exec := &Executor{binaryPath: "/usr/bin/echo"}
176+
result, err := exec.Execute(context.Background(), executor.Request{
177+
TaskID: "herald-test01",
178+
Prompt: "hello",
179+
ProjectPath: t.TempDir(),
180+
}, func(eventType, message string) {})
181+
182+
require.NoError(t, err)
183+
assert.Equal(t, 0, result.ExitCode)
184+
}
185+
```

0 commit comments

Comments
 (0)