tatara-claude-code-wrapper Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A single-session Claude Code supervisor container that drives the real interactive claude REPL over a PTY (never -p), submits one user turn at a time, captures each turn's result via a custom Stop hook, and exposes it through an OIDC-gated HTTP API with webhook-or-poll delivery.
Architecture: Go service (wrapper) is PID 1 in the pod. At boot it renders config files (global/project CLAUDE.md, merged .mcp.json, settings.json wiring a Stop hook, installs skills, optional repo clone), then spawns interactive claude attached to a PTY. POST /v1/messages types a message into the PTY and returns 202 {turnId}; at end-of-turn the cc-stop-hook binary reads the transcript and POSTs the result to the wrapper's loopback-only internal endpoint; the wrapper persists it and delivers via optional webhook (poll fallback always available). One claude process per pod, turns strictly sequential (409 while busy). Reuses tatara-memory's internal/auth and internal/obs verbatim.
Tech Stack: Go (newest stable, pin in go.mod), github.com/creack/pty, github.com/go-chi/chi/v5, github.com/coreos/go-oidc/v3, github.com/prometheus/client_golang, github.com/stretchr/testify. Node + @anthropic-ai/claude-code for the runtime CLI. Helm chart via helm create. CI via existing tatara-argo-workflows; release in ~/Documents/infra/helmfile for v0.1.0.
Repo: new github.com/szymonrychu/tatara-claude-code-wrapper, cloned to ~/Documents/tatara/tatara-claude-code-wrapper/ (gitignored in parent).
File Structure¶
tatara-claude-code-wrapper/
├── go.mod # module github.com/szymonrychu/tatara-claude-code-wrapper
├── Makefile # build/test/lint/image + bump-claude
├── Dockerfile # multi-stage: go build -> node runtime; ARG CLAUDE_CODE_VERSION
├── .golangci.yml # v2 schema, copied from tatara-cli
├── .gitignore .dockerignore LICENSE
├── CLAUDE.md # copy of parent CLAUDE.md
├── MEMORY.md ROADMAP.md README.md
├── cmd/
│ ├── wrapper/ # main service
│ │ ├── main.go config.go app.go
│ │ └── config_test.go app_test.go integration_test.go
│ └── cc-stop-hook/ # Stop hook binary
│ ├── main.go hook.go transcript.go
│ └── hook_test.go transcript_test.go
├── internal/
│ ├── version/version.go # copied from tatara-memory
│ ├── obs/{logger.go,registry.go} # copied subset from tatara-memory
│ ├── auth/{auth.go,verifier.go,middleware.go,testjwks/...} # copied
│ ├── metrics/metrics.go # ccw_* collectors
│ ├── turn/{turn.go,store.go} # turn records + in-memory store
│ ├── session/{session.go,pty.go} # Manager: PTY spawn + submit + complete
│ ├── webhook/webhook.go # async retrying delivery
│ ├── bootstrap/{bootstrap.go,mcp.go,settings.go,skills.go,repo.go}
│ └── httpapi/{api.go,messages.go,session.go,internal.go}
├── templates/skills/handoff/SKILL.md # baked default skill
└── charts/tatara-claude-code-wrapper/ # helm chart
Responsibility boundaries (each file one job): - turn owns the turn record type + thread-safe store. No I/O. - session owns the claude process + PTY + turn-in-flight state machine. Depends on turn, metrics. Emits a "turn done" callback; knows nothing about HTTP or webhooks. - webhook owns retrying HTTP delivery. Depends on turn (payload), metrics. - bootstrap owns rendering boot files. Pure filesystem; injectable git runner. - httpapi owns routing/handlers + OIDC. Depends on a SessionController interface (satisfied by session.Manager) + turn.Store. - cmd/wrapper wires everything; cmd/cc-stop-hook is a standalone binary.
Conventions (match sibling repos)¶
- Module path
github.com/szymonrychu/tatara-claude-code-wrapper. - Errors wrapped:
fmt.Errorf("context: %w", err). slogJSON viaobs.NewLogger. Business actions at INFO withturn_id,session_id,duration_ms.- Tests: testify
require, table-driven witht.Run. - Chart ConfigMap keys are UPPER_SNAKE (matches tatara-memory
envConfighelper + consolidation P8), consumed viaenvFrom. camelCase scalar invalues.yaml-> UPPER_SNAKE ConfigMap/Secret key -> env. Lists and file-shaped data go into separate mounted ConfigMaps, never inline.
Task 0: Create the repo on GitHub and clone¶
Files: none yet (scaffold).
- Step 1: Create the empty private repo and clone
cd ~/Documents/tatara
gh repo create szymonrychu/tatara-claude-code-wrapper --private \
--description "Single-session Claude Code supervisor (interactive PTY + Stop-hook API)"
git clone git@github.com:szymonrychu/tatara-claude-code-wrapper.git
cd tatara-claude-code-wrapper
Expected: empty repo cloned into ~/Documents/tatara/tatara-claude-code-wrapper.
- Step 2: Confirm parent gitignore already excludes it
Run: cd ~/Documents/tatara && git check-ignore tatara-claude-code-wrapper Expected: prints tatara-claude-code-wrapper (already covered per .gitignore; if not, add it and commit separately in the parent repo).
Task 1: SPIKE — pin interactive PTY driving + Stop hook against the real binary¶
This de-risks the whole architecture. No production code is written from guesses. We run the real claude under a PTY and capture exact fixtures. Everything downstream is TDD'd against these.
Files: - Create: hack/spike/drive.go (throwaway; deleted at task end) - Create (committed fixtures): internal/session/testdata/hook_payload.json, internal/session/testdata/transcript_assistant_line.jsonl, cmd/cc-stop-hook/testdata/hook_payload.json, cmd/cc-stop-hook/testdata/transcript.jsonl - Create: docs/spike-findings.md
- Step 1: Stand up claude + a capture hook in a scratch dir
mkdir -p /tmp/ccw-spike/.claude/skills /tmp/ccw-spike/.cap
cd /tmp/ccw-spike
# capture hook: dumps its stdin payload, then exits 0 (pure side effect)
cat > .cap/hook.sh <<'EOF'
#!/bin/sh
cat > /tmp/ccw-spike/.cap/last_hook_payload.json
exit 0
EOF
chmod +x .cap/hook.sh
cat > .claude/settings.json <<'EOF'
{
"hooks": { "Stop": [ { "matcher": "", "hooks": [ { "type": "command", "command": "/tmp/ccw-spike/.cap/hook.sh" } ] } ] },
"enableAllProjectMcpServers": true
}
EOF
- Step 2: Write the throwaway PTY driver
hack/spike/drive.go (in the new repo, temporarily): allocate a PTY, spawn claude --permission-mode bypassPermissions with HOME=/tmp/ccw-spike, cwd=/tmp/ccw-spike, TERM=xterm-256color; copy PTY output to stdout in a goroutine; after a 5s boot wait, write a test message using bracketed paste then \r; wait for .cap/last_hook_payload.json to appear; print it; exit.
package main
import (
"fmt"
"os"
"os/exec"
"time"
"github.com/creack/pty"
)
func main() {
cmd := exec.Command("claude", "--permission-mode", "bypassPermissions")
cmd.Env = append(os.Environ(),
"HOME=/tmp/ccw-spike", "TERM=xterm-256color")
cmd.Dir = "/tmp/ccw-spike"
ptmx, err := pty.Start(cmd)
if err != nil {
panic(err)
}
defer func() { _ = ptmx.Close() }()
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: 40, Cols: 120})
go func() { _, _ = io.Copy(os.Stdout, ptmx) }() // observe TUI
time.Sleep(8 * time.Second) // boot
msg := "Reply with exactly the word PONG and nothing else."
// bracketed paste so newlines never submit early, then CR to submit
_, _ = ptmx.Write([]byte("\x1b[200~" + msg + "\x1b[201~"))
time.Sleep(200 * time.Millisecond)
_, _ = ptmx.Write([]byte("\r"))
for i := 0; i < 120; i++ {
if _, err := os.Stat("/tmp/ccw-spike/.cap/last_hook_payload.json"); err == nil {
b, _ := os.ReadFile("/tmp/ccw-spike/.cap/last_hook_payload.json")
fmt.Printf("\n=== HOOK PAYLOAD ===\n%s\n", b)
return
}
time.Sleep(time.Second)
}
fmt.Println("TIMEOUT: no hook fired")
}
(Add io to imports.)
- Step 3: Run the spike and capture reality
cd ~/Documents/tatara/tatara-claude-code-wrapper
export ANTHROPIC_API_KEY=... # real key from sops/op for the spike only
go mod init github.com/szymonrychu/tatara-claude-code-wrapper
go get github.com/creack/pty
go run ./hack/spike/drive.go | tee /tmp/ccw-spike/run.log
Expected: TUI renders; the typed message submits as ONE turn; the Stop hook fires; the printed HOOK PAYLOAD JSON contains at least session_id, transcript_path, hook_event_name":"Stop". Record what else is present (stop_reason, messages, cwd, permission_mode, stop_hook_active).
If the message does NOT submit, iterate on the submit sequence in Step 2 (candidates, in order: \r; \n; paste then \r; \x0d; double \r). Record the working sequence.
- Step 4: Capture committed fixtures
cp /tmp/ccw-spike/.cap/last_hook_payload.json internal/session/testdata/hook_payload.json
mkdir -p cmd/cc-stop-hook/testdata
cp /tmp/ccw-spike/.cap/last_hook_payload.json cmd/cc-stop-hook/testdata/hook_payload.json
# copy the real transcript the payload points at:
TP=$(python3 -c "import json,sys;print(json.load(open('internal/session/testdata/hook_payload.json'))['transcript_path'])")
cp "$TP" cmd/cc-stop-hook/testdata/transcript.jsonl
# extract one assistant line for the session-level fixture:
grep '"type":"assistant"' "$TP" | tail -1 > internal/session/testdata/transcript_assistant_line.jsonl
- Step 5: Record findings, then verify a SECOND turn works (persistence)
Extend the spike to send a second message after the first hook fires (write another bracketed-paste + \r), confirm a second hook fires WITHOUT respawning claude. Write docs/spike-findings.md capturing: the exact submit byte sequence, the boot-readiness signal (what TUI text/escape marks "prompt ready"), the full hook payload field list, the transcript assistant line shape, whether --mcp-config was needed or cwd .mcp.json + enableAllProjectMcpServers sufficed to avoid an approval prompt, and the flags that prevented any blocking permission prompt.
- Step 6: Remove the throwaway driver, commit fixtures + findings
rm -rf hack/spike
git add internal/session/testdata cmd/cc-stop-hook/testdata docs/spike-findings.md go.mod go.sum
git commit -m "spike: capture interactive PTY submit sequence + Stop hook fixtures"
Downstream tasks consume
docs/spike-findings.md. Wherever this plan gives a provisional submit sequence or readiness heuristic, the spike findings are authoritative — adjust the constant to match the captured reality before the test goes green.
Task 2: Scaffold module, copy reusable packages, tooling¶
Files: - Create: go.mod (already init'd in Task 1), Makefile, .golangci.yml, .gitignore, .dockerignore, LICENSE, CLAUDE.md, MEMORY.md, ROADMAP.md, README.md - Create: internal/version/version.go - Copy: internal/obs/, internal/auth/ from tatara-memory (module path rewritten)
- Step 1: Pin Go and copy tooling from siblings
cd ~/Documents/tatara/tatara-claude-code-wrapper
GOV=$(go version | grep -oE '1\.[0-9]+\.[0-9]+')
go mod edit -go=${GOV%.*} # e.g. go 1.25
cp ../tatara-cli/.golangci.yml .
cp ../tatara-memory/LICENSE .
cp ../CLAUDE.md . # parent contract, per hard rules
printf 'bin/\ndist/\n*.log\n' > .gitignore
printf '.git\nbin\ndist\ncharts\ndocs\n*.md\n' > .dockerignore
- Step 2: Copy version/obs/auth, rewrite the module path
mkdir -p internal
cp -r ../tatara-memory/internal/version internal/version
cp -r ../tatara-memory/internal/obs internal/obs
cp -r ../tatara-memory/internal/auth internal/auth
# rewrite import paths to the new module
grep -rl 'szymonrychu/tatara-memory' internal | xargs \
sed -i '' 's#szymonrychu/tatara-memory#szymonrychu/tatara-claude-code-wrapper#g'
Keep only what obs needs (NewLogger, PromRegistry/registry, and their tests). Delete internal/obs/tracing*.go and tracing-only tests if OTLP is not wired in v0.1.0 (it isn't — drop them).
- Step 3: Author the Makefile
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
REGISTRY ?= harbor.szymonrichert.pl
IMAGE_NAME ?= containers/tatara-claude-code-wrapper
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
CLAUDE_CODE_VERSION ?= latest
TATARA_CLI_VERSION ?= latest
IMAGE_REF := $(REGISTRY)/$(IMAGE_NAME):$(VERSION)
MODPATH := github.com/szymonrychu/tatara-claude-code-wrapper
.PHONY: all lint test build image push tidy fmt clean chart-test bump-claude
all: lint test build
tidy: ; go mod tidy
fmt:
gofmt -s -w .
goimports -w -local $(MODPATH) .
lint: ; golangci-lint run ./... || [ $$? -eq 5 ]
test: ; go test ./... -race -count=1
build:
CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X $(MODPATH)/internal/version.Version=$(VERSION) -X $(MODPATH)/internal/version.Commit=$(COMMIT) -X $(MODPATH)/internal/version.Date=$(DATE)" \
-o bin/wrapper ./cmd/wrapper
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o bin/cc-stop-hook ./cmd/cc-stop-hook
image:
docker buildx build --platform=linux/amd64 \
--build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg DATE=$(DATE) \
--build-arg CLAUDE_CODE_VERSION=$(CLAUDE_CODE_VERSION) --build-arg TATARA_CLI_VERSION=$(TATARA_CLI_VERSION) \
-t $(IMAGE_REF) --load .
push: image ; docker push $(IMAGE_REF)
chart-test: ; helm unittest charts/tatara-claude-code-wrapper
bump-claude:
@test -n "$(VERSION_ARG)" || { echo "usage: make bump-claude VERSION_ARG=1.2.3"; exit 1; }
sed -i '' 's/^ARG CLAUDE_CODE_VERSION=.*/ARG CLAUDE_CODE_VERSION=$(VERSION_ARG)/' Dockerfile
clean: ; rm -rf bin dist
- Step 4: Verify it compiles and copied tests pass
Run: go mod tidy && go build ./... && go test ./internal/... -count=1 Expected: build OK; internal/auth and internal/obs tests PASS (proven code, just re-homed).
- Step 5: Commit
Task 3: turn record + in-memory store¶
Files: - Create: internal/turn/turn.go, internal/turn/store.go - Test: internal/turn/store_test.go
- Step 1: Write the failing test
package turn_test
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
)
func TestStore_CreateCompleteGet(t *testing.T) {
s := turn.NewStore()
t0 := time.Unix(1000, 0)
rec := s.Create("turn-1", "hello", "https://cb.example/x", t0)
require.Equal(t, turn.Running, rec.State)
got, ok := s.Get("turn-1")
require.True(t, ok)
require.Equal(t, turn.Running, got.State)
require.Equal(t, "https://cb.example/x", got.CallbackURL)
t1 := time.Unix(1005, 0)
require.NoError(t, s.Complete("turn-1", "PONG",
json.RawMessage(`{"status":"success"}`), json.RawMessage(`{"output_tokens":3}`), "end_turn", t1))
got, _ = s.Get("turn-1")
require.Equal(t, turn.Complete, got.State)
require.Equal(t, "PONG", got.FinalText)
require.Equal(t, "end_turn", got.StopReason)
require.NotNil(t, got.CompletedAt)
require.ErrorIs(t, s.Complete("missing", "", nil, nil, "", t1), turn.ErrNotFound)
}
func TestStore_ListOrderedAndFail(t *testing.T) {
s := turn.NewStore()
s.Create("a", "1", "", time.Unix(1, 0))
s.Create("b", "2", "", time.Unix(2, 0))
require.NoError(t, s.Fail("a", "boom", time.Unix(3, 0)))
list := s.List()
require.Len(t, list, 2)
require.Equal(t, "a", list[0].ID)
require.Equal(t, turn.Failed, list[0].State)
require.Equal(t, "b", list[1].ID)
}
- Step 2: Run, verify it fails
Run: go test ./internal/turn/ -run TestStore -v Expected: FAIL (package turn does not compile / undefined).
- Step 3: Implement
turn.go
// Package turn holds per-turn records and a thread-safe store.
package turn
import (
"encoding/json"
"time"
)
type State string
const (
Running State = "running"
Complete State = "complete"
Failed State = "failed"
)
// Record is one user turn and its eventual result.
type Record struct {
ID string `json:"turnId"`
State State `json:"state"`
Text string `json:"-"`
CallbackURL string `json:"-"`
FinalText string `json:"finalText,omitempty"`
ResultJSON json.RawMessage `json:"resultJson,omitempty"`
Usage json.RawMessage `json:"usage,omitempty"`
StopReason string `json:"stopReason,omitempty"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
// Summary is the compact form returned by List.
type Summary struct {
ID string `json:"turnId"`
State State `json:"state"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
- Step 4: Implement
store.go
package turn
import (
"encoding/json"
"errors"
"sync"
"time"
)
var ErrNotFound = errors.New("turn: not found")
type Store struct {
mu sync.RWMutex
byID map[string]*Record
order []string
}
func NewStore() *Store { return &Store{byID: map[string]*Record{}} }
func (s *Store) Create(id, text, callbackURL string, now time.Time) *Record {
s.mu.Lock()
defer s.mu.Unlock()
rec := &Record{ID: id, State: Running, Text: text, CallbackURL: callbackURL, StartedAt: now}
s.byID[id] = rec
s.order = append(s.order, id)
return rec
}
func (s *Store) Get(id string) (*Record, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
rec, ok := s.byID[id]
if !ok {
return nil, false
}
cp := *rec
return &cp, true
}
func (s *Store) List() []Summary {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Summary, 0, len(s.order))
for _, id := range s.order {
r := s.byID[id]
out = append(out, Summary{ID: r.ID, State: r.State, StartedAt: r.StartedAt, CompletedAt: r.CompletedAt})
}
return out
}
func (s *Store) Complete(id, finalText string, resultJSON, usage json.RawMessage, stopReason string, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.byID[id]
if !ok {
return ErrNotFound
}
r.State, r.FinalText, r.ResultJSON, r.Usage, r.StopReason = Complete, finalText, resultJSON, usage, stopReason
r.CompletedAt = &now
return nil
}
func (s *Store) Fail(id, msg string, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.byID[id]
if !ok {
return ErrNotFound
}
r.State, r.Error, r.CompletedAt = Failed, msg, &now
return nil
}
- Step 5: Run tests, then commit
Run: go test ./internal/turn/ -race -count=1 Expected: PASS.
Task 4: ccw_* prometheus metrics¶
Files: - Create: internal/metrics/metrics.go - Test: internal/metrics/metrics_test.go
- Step 1: Write the failing test
package metrics_test
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/metrics"
)
func TestNew_RegistersAllCollectors(t *testing.T) {
reg := prometheus.NewRegistry()
m := metrics.New(reg)
require.NotNil(t, m)
m.TurnsTotal.WithLabelValues("complete").Inc()
m.TurnDuration.Observe(1.2)
m.TurnInFlight.Set(1)
m.ClaudeRestarts.Inc()
m.WebhookDelivery.WithLabelValues("ok").Inc()
m.HookReceived.Inc()
mfs, err := reg.Gather()
require.NoError(t, err)
names := map[string]bool{}
for _, mf := range mfs {
names[mf.GetName()] = true
}
for _, want := range []string{
"ccw_turns_total", "ccw_turn_duration_seconds", "ccw_turn_in_flight",
"ccw_claude_restarts_total", "ccw_webhook_delivery_total", "ccw_hook_received_total",
} {
require.True(t, names[want], "missing %s", want)
}
}
- Step 2: Run, verify it fails
Run: go test ./internal/metrics/ -v Expected: FAIL (undefined metrics).
- Step 3: Implement
metrics.go
// Package metrics holds the wrapper's prometheus collectors.
package metrics
import "github.com/prometheus/client_golang/prometheus"
type Metrics struct {
TurnsTotal *prometheus.CounterVec
TurnDuration prometheus.Histogram
TurnInFlight prometheus.Gauge
ClaudeRestarts prometheus.Counter
WebhookDelivery *prometheus.CounterVec
HookReceived prometheus.Counter
}
func New(reg prometheus.Registerer) *Metrics {
m := &Metrics{
TurnsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ccw_turns_total", Help: "Turns by terminal result."}, []string{"result"}),
TurnDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "ccw_turn_duration_seconds", Help: "Turn wall-clock duration.",
Buckets: prometheus.ExponentialBuckets(1, 2, 12)}),
TurnInFlight: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ccw_turn_in_flight", Help: "Turns currently in flight (0 or 1)."}),
ClaudeRestarts: prometheus.NewCounter(prometheus.CounterOpts{
Name: "ccw_claude_restarts_total", Help: "claude process restarts."}),
WebhookDelivery: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ccw_webhook_delivery_total", Help: "Webhook deliveries by result."}, []string{"result"}),
HookReceived: prometheus.NewCounter(prometheus.CounterOpts{
Name: "ccw_hook_received_total", Help: "Stop-hook callbacks received."}),
}
reg.MustRegister(m.TurnsTotal, m.TurnDuration, m.TurnInFlight,
m.ClaudeRestarts, m.WebhookDelivery, m.HookReceived)
return m
}
- Step 4: Run, then commit
Run: go test ./internal/metrics/ -race -count=1 -> PASS
Task 5: webhook sender (async, retrying)¶
Files: - Create: internal/webhook/webhook.go - Test: internal/webhook/webhook_test.go
- Step 1: Write the failing test
package webhook_test
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/metrics"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/webhook"
)
func newSender(t *testing.T, retries int) *webhook.Sender {
t.Helper()
return webhook.New(webhook.Config{Retries: retries, Backoff: time.Millisecond},
metrics.New(prometheus.NewRegistry()), discardLogger())
}
func TestDeliver_SucceedsAfterRetry(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if atomic.AddInt32(&hits, 1) < 2 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
s := newSender(t, 3)
rec := &turn.Record{ID: "t1", State: turn.Complete, FinalText: "PONG"}
s.Deliver(context.Background(), srv.URL, rec)
require.Eventually(t, func() bool { return atomic.LoadInt32(&hits) == 2 }, time.Second, 5*time.Millisecond)
}
func TestDeliver_EmptyURLIsNoop(t *testing.T) {
s := newSender(t, 1)
s.Deliver(context.Background(), "", &turn.Record{ID: "t1"}) // must not panic
}
Add a tiny discardLogger() helper in the test file:
io, log/slog.) - Step 2: Run, verify it fails
Run: go test ./internal/webhook/ -v Expected: FAIL (undefined webhook).
- Step 3: Implement
webhook.go
// Package webhook delivers turn results to caller-supplied callback URLs.
package webhook
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/metrics"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
)
type Config struct {
Retries int
Backoff time.Duration
}
type Sender struct {
cfg Config
client *http.Client
m *metrics.Metrics
log *slog.Logger
}
func New(cfg Config, m *metrics.Metrics, log *slog.Logger) *Sender {
if cfg.Backoff <= 0 {
cfg.Backoff = time.Second
}
return &Sender{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}, m: m, log: log}
}
// Deliver posts the record to url asynchronously, retrying with exponential
// backoff. A blank url is a no-op (poll-only callers).
func (s *Sender) Deliver(ctx context.Context, url string, rec *turn.Record) {
if url == "" {
return
}
body, err := json.Marshal(rec)
if err != nil {
s.log.Error("webhook: marshal", "err", err, "turn_id", rec.ID)
return
}
go s.deliver(ctx, url, rec.ID, body)
}
func (s *Sender) deliver(ctx context.Context, url, turnID string, body []byte) {
backoff := s.cfg.Backoff
for attempt := 0; attempt <= s.cfg.Retries; attempt++ {
if err := s.post(ctx, url, body); err != nil {
s.log.Warn("webhook: attempt failed", "err", err, "turn_id", turnID, "attempt", attempt)
select {
case <-ctx.Done():
s.m.WebhookDelivery.WithLabelValues("dropped").Inc()
return
case <-time.After(backoff):
}
backoff *= 2
continue
}
s.m.WebhookDelivery.WithLabelValues("ok").Inc()
s.log.Info("webhook: delivered", "turn_id", turnID, "url", url)
return
}
s.m.WebhookDelivery.WithLabelValues("dropped").Inc()
s.log.Error("webhook: dropped after retries", "turn_id", turnID, "url", url)
}
func (s *Sender) post(ctx context.Context, url string, body []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("do: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
return fmt.Errorf("status %d", resp.StatusCode)
}
return nil
}
- Step 4: Run, then commit
Run: go test ./internal/webhook/ -race -count=1 -> PASS
Task 6: bootstrap — render boot files¶
Files: - Create: internal/bootstrap/bootstrap.go, mcp.go, settings.go, skills.go, repo.go, claudejson.go - Test: internal/bootstrap/bootstrap_test.go, mcp_test.go, settings_test.go, claudejson_test.go
Spike-confirmed (Task 1,
docs/spike-findings.md): a fresh$HOMEshows blocking boot dialogs unless~/.claude.jsonis pre-seeded. This task therefore ALSO writes~/.claude.jsonviaclaudejson.go(Step 6b) withhasCompletedOnboarding:true,customApiKeyResponses.approvedset to the LAST 20 CHARS of the Anthropic API key, andprojects[Workspace].hasTrustDialogAccepted:true.ParamsgainsAnthropicAPIKey string.settings.gosetspermissions.defaultMode(fromPermissionMode) +enableAllProjectMcpServers(already specified).
This package is pure filesystem with an injected git runner, so it is fully unit-testable in a temp dir.
- Step 1: Write the failing tests
package bootstrap_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/bootstrap"
)
func TestRender_WritesClaudeMdSettingsSkillsAndMergesMCP(t *testing.T) {
home := t.TempDir()
ws := t.TempDir()
overlay := t.TempDir()
skillsSrc := t.TempDir()
// a baked skill source
require.NoError(t, os.MkdirAll(filepath.Join(skillsSrc, "handoff"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(skillsSrc, "handoff", "SKILL.md"), []byte("# /handoff"), 0o644))
// an mcp overlay fragment
require.NoError(t, os.WriteFile(filepath.Join(overlay, "tasks.json"),
[]byte(`{"mcpServers":{"tasks":{"type":"stdio","command":"/bin/tasks"}}}`), 0o644))
var gitCalls [][]string
p := bootstrap.Params{
HomeDir: home, Workspace: ws,
GlobalClaudeMd: "GLOBAL RULES",
ProjectClaudeMd: "PROJECT RULES",
BaseMCP: []byte(`{"mcpServers":{"tatara-memory":{"type":"stdio","command":"tatara","args":["mcp"]}}}`),
MCPOverlayDir: overlay,
SkillsSrc: []string{skillsSrc},
HookCommand: "/usr/local/bin/cc-stop-hook",
AllowedTools: []string{"Bash", "Edit"},
EnableAllMCP: true,
PermissionMode: "bypassPermissions",
}
require.NoError(t, bootstrap.Render(p, func(a ...string) error { gitCalls = append(gitCalls, a); return nil }))
// global + project CLAUDE.md
b, _ := os.ReadFile(filepath.Join(home, ".claude", "CLAUDE.md"))
require.Equal(t, "GLOBAL RULES", string(b))
b, _ = os.ReadFile(filepath.Join(ws, "CLAUDE.md"))
require.Equal(t, "PROJECT RULES", string(b))
// merged .mcp.json has BOTH servers
b, _ = os.ReadFile(filepath.Join(ws, ".mcp.json"))
var mcp struct{ MCPServers map[string]any `json:"mcpServers"` }
require.NoError(t, json.Unmarshal(b, &mcp))
require.Contains(t, mcp.MCPServers, "tatara-memory")
require.Contains(t, mcp.MCPServers, "tasks")
// settings.json wires Stop hook + enableAllProjectMcpServers
b, _ = os.ReadFile(filepath.Join(home, ".claude", "settings.json"))
require.Contains(t, string(b), "/usr/local/bin/cc-stop-hook")
require.Contains(t, string(b), "enableAllProjectMcpServers")
// skill copied
b, _ = os.ReadFile(filepath.Join(ws, ".claude", "skills", "handoff", "SKILL.md"))
require.Equal(t, "# /handoff", string(b))
// no repo configured -> git not called
require.Empty(t, gitCalls)
}
func TestRender_ClonesRepoWhenURLSet(t *testing.T) {
var gitCalls [][]string
p := bootstrap.Params{
HomeDir: t.TempDir(), Workspace: t.TempDir(),
BaseMCP: []byte(`{"mcpServers":{}}`),
RepoURL: "https://github.com/x/y", RepoBranch: "main",
HookCommand: "/usr/local/bin/cc-stop-hook", PermissionMode: "bypassPermissions",
}
require.NoError(t, bootstrap.Render(p, func(a ...string) error { gitCalls = append(gitCalls, a); return nil }))
require.Len(t, gitCalls, 1)
require.Contains(t, gitCalls[0], "clone")
require.Contains(t, gitCalls[0], "https://github.com/x/y")
require.Contains(t, gitCalls[0], "main")
}
- Step 2: Run, verify it fails
Run: go test ./internal/bootstrap/ -v Expected: FAIL (undefined bootstrap).
- Step 3: Implement
bootstrap.go
// Package bootstrap renders the per-session files claude reads at startup.
package bootstrap
import (
"fmt"
"os"
"path/filepath"
)
type Params struct {
HomeDir, Workspace string
GlobalClaudeMd, ProjectClaudeMd string
BaseMCP []byte
MCPOverlayDir string
SkillsSrc []string
HookCommand string
AllowedTools []string
EnableAllMCP bool
PermissionMode string
AnthropicAPIKey string // used to seed customApiKeyResponses (last 20 chars)
RepoURL, RepoBranch string
}
// GitRunner runs a git subcommand; injected for testability.
type GitRunner func(args ...string) error
func Render(p Params, git GitRunner) error {
claudeHome := filepath.Join(p.HomeDir, ".claude")
if err := os.MkdirAll(claudeHome, 0o755); err != nil {
return fmt.Errorf("mkdir claude home: %w", err)
}
if err := os.MkdirAll(p.Workspace, 0o755); err != nil {
return fmt.Errorf("mkdir workspace: %w", err)
}
if p.RepoURL != "" {
if err := cloneRepo(p, git); err != nil {
return err
}
}
if err := writeIfSet(filepath.Join(p.Workspace, "CLAUDE.md"), p.ProjectClaudeMd); err != nil {
return err
}
if err := writeIfSet(filepath.Join(claudeHome, "CLAUDE.md"), p.GlobalClaudeMd); err != nil {
return err
}
if err := mergeMCP(p); err != nil {
return err
}
if err := writeSettings(p, claudeHome); err != nil {
return err
}
if err := writeClaudeJSON(p); err != nil {
return err
}
return installSkills(p)
}
func writeIfSet(path, content string) error {
if content == "" {
return nil
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
- Step 4: Implement
mcp.go
package bootstrap
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type mcpDoc struct {
MCPServers map[string]json.RawMessage `json:"mcpServers"`
}
// mergeMCP unions the baked base config with every *.json fragment in the
// overlay dir and writes /workspace/.mcp.json. Overlay keys win on conflict.
func mergeMCP(p Params) error {
merged := mcpDoc{MCPServers: map[string]json.RawMessage{}}
if len(p.BaseMCP) > 0 {
var base mcpDoc
if err := json.Unmarshal(p.BaseMCP, &base); err != nil {
return fmt.Errorf("parse base mcp: %w", err)
}
for k, v := range base.MCPServers {
merged.MCPServers[k] = v
}
}
if p.MCPOverlayDir != "" {
entries, err := os.ReadDir(p.MCPOverlayDir)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read mcp overlay: %w", err)
}
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
b, err := os.ReadFile(filepath.Join(p.MCPOverlayDir, e.Name()))
if err != nil {
return fmt.Errorf("read overlay %s: %w", e.Name(), err)
}
var frag mcpDoc
if err := json.Unmarshal(b, &frag); err != nil {
return fmt.Errorf("parse overlay %s: %w", e.Name(), err)
}
for k, v := range frag.MCPServers {
merged.MCPServers[k] = v
}
}
}
out, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return fmt.Errorf("marshal mcp: %w", err)
}
if err := os.WriteFile(filepath.Join(p.Workspace, ".mcp.json"), out, 0o644); err != nil {
return fmt.Errorf("write .mcp.json: %w", err)
}
return nil
}
- Step 5: Implement
settings.go
package bootstrap
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func writeSettings(p Params, claudeHome string) error {
type hookCmd struct {
Type string `json:"type"`
Command string `json:"command"`
}
type hookMatcher struct {
Matcher string `json:"matcher"`
Hooks []hookCmd `json:"hooks"`
}
settings := map[string]any{
"hooks": map[string]any{
"Stop": []hookMatcher{{Matcher: "", Hooks: []hookCmd{{Type: "command", Command: p.HookCommand}}}},
},
"enableAllProjectMcpServers": p.EnableAllMCP,
}
if p.PermissionMode != "" || len(p.AllowedTools) > 0 {
perms := map[string]any{}
if p.PermissionMode != "" {
perms["defaultMode"] = p.PermissionMode
}
if len(p.AllowedTools) > 0 {
perms["allow"] = p.AllowedTools
}
settings["permissions"] = perms
}
out, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("marshal settings: %w", err)
}
if err := os.WriteFile(filepath.Join(claudeHome, "settings.json"), out, 0o644); err != nil {
return fmt.Errorf("write settings.json: %w", err)
}
return nil
}
Spike note: if
docs/spike-findings.mdshows--mcp-configor a different permission key was required for headless auto-approval, reflect it here (and in the claude args built in Task 8) before the integration test.
- Step 6: Implement
skills.goandrepo.go
// skills.go
package bootstrap
import (
"fmt"
"io"
"os"
"path/filepath"
)
// installSkills copies every skill tree under each SkillsSrc dir into
// /workspace/.claude/skills, so baked + custom skills coexist.
func installSkills(p Params) error {
dst := filepath.Join(p.Workspace, ".claude", "skills")
if err := os.MkdirAll(dst, 0o755); err != nil {
return fmt.Errorf("mkdir skills: %w", err)
}
for _, src := range p.SkillsSrc {
if src == "" {
continue
}
if _, err := os.Stat(src); os.IsNotExist(err) {
continue
}
if err := copyTree(src, dst); err != nil {
return fmt.Errorf("install skills from %s: %w", src, err)
}
}
return nil
}
func copyTree(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, path)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(path, target)
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.Create(dst)
if err != nil {
return err
}
defer func() { _ = out.Close() }()
_, err = io.Copy(out, in)
return err
}
// repo.go
package bootstrap
// cloneRepo shallow-clones RepoURL@RepoBranch into the workspace.
func cloneRepo(p Params, git GitRunner) error {
args := []string{"clone", "--depth", "1"}
if p.RepoBranch != "" {
args = append(args, "--branch", p.RepoBranch)
}
args = append(args, p.RepoURL, p.Workspace)
if err := git(args...); err != nil {
return err
}
return nil
}
- Step 6b: Implement
claudejson.go(no-dialog seed) + test
Test (claudejson_test.go):
func TestWriteClaudeJSON_SeedsNoDialogKeys(t *testing.T) {
home := t.TempDir()
p := bootstrap.Params{HomeDir: home, Workspace: "/workspace",
AnthropicAPIKey: "sk-ant-XXXXXXXXXXXXXXXXXXXXEentiTPHC9Q-62Rz1wAA"}
require.NoError(t, bootstrap.WriteClaudeJSONForTest(p)) // thin exported wrapper for the test
b, _ := os.ReadFile(filepath.Join(home, ".claude.json"))
var doc map[string]any
require.NoError(t, json.Unmarshal(b, &doc))
require.Equal(t, true, doc["hasCompletedOnboarding"])
approved := doc["customApiKeyResponses"].(map[string]any)["approved"].([]any)
require.Equal(t, "EentiTPHC9Q-62Rz1wAA", approved[0]) // last 20 chars
proj := doc["projects"].(map[string]any)["/workspace"].(map[string]any)
require.Equal(t, true, proj["hasTrustDialogAccepted"])
}
Implementation (claudejson.go):
package bootstrap
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// writeClaudeJSON seeds ~/.claude.json so a fresh-HOME unattended claude boots
// with no interactive dialogs (onboarding, folder trust, custom-API-key).
// Recipe confirmed by the Task-1 spike (docs/spike-findings.md).
func writeClaudeJSON(p Params) error {
doc := map[string]any{
"hasCompletedOnboarding": true,
"autoUpdates": true,
"projects": map[string]any{
p.Workspace: map[string]any{"hasTrustDialogAccepted": true},
},
}
if p.AnthropicAPIKey != "" {
doc["customApiKeyResponses"] = map[string]any{
"approved": []string{lastN(p.AnthropicAPIKey, 20)},
"rejected": []string{},
}
}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return fmt.Errorf("marshal claude.json: %w", err)
}
if err := os.WriteFile(filepath.Join(p.HomeDir, ".claude.json"), b, 0o644); err != nil {
return fmt.Errorf("write claude.json: %w", err)
}
return nil
}
func lastN(s string, n int) string {
if len(s) <= n {
return s
}
return s[len(s)-n:]
}
// WriteClaudeJSONForTest exposes writeClaudeJSON for the package test.
func WriteClaudeJSONForTest(p Params) error { return writeClaudeJSON(p) }
- Step 7: Run all bootstrap tests, then commit
Run: go test ./internal/bootstrap/ -race -count=1 -> PASS
git add internal/bootstrap && git commit -m "feat: bootstrap renders CLAUDE.md, merged MCP, settings, skills, repo clone, claude.json seed"
Task 7: session manager — PTY spawn, submit, complete, timeout¶
Files: - Create: internal/session/session.go, internal/session/pty.go - Test: internal/session/session_test.go
The PTY is driven through a tiny ptyProc seam so tests can substitute a fake without a real terminal. Process spawning lives in pty.go; the state machine in session.go.
- Step 1: Write the failing test (state machine with a fake PTY)
package session_test
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/metrics"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/session"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
)
type fakePTY struct {
mu sync.Mutex
written []byte
}
func (f *fakePTY) Write(p []byte) (int, error) { f.mu.Lock(); defer f.mu.Unlock(); f.written = append(f.written, p...); return len(p), nil }
func (f *fakePTY) bytes() []byte { f.mu.Lock(); defer f.mu.Unlock(); return append([]byte(nil), f.written...) }
func (f *fakePTY) Close() error { return nil }
func newMgr(t *testing.T, fp *fakePTY) (*session.Manager, *turn.Store) {
t.Helper()
store := turn.NewStore()
ids := make(chan string, 8)
ids <- "turn-1"
ids <- "turn-2"
m := session.New(session.Config{TurnTimeout: 50 * time.Millisecond, SubmitSeq: session.DefaultSubmitSeq},
store, metrics.New(prometheus.NewRegistry()),
slog.New(slog.NewTextHandler(io.Discard, nil)),
func() time.Time { return time.Unix(100, 0) },
func() string { return <-ids })
m.SetWriterForTest(fp) // injects fake PTY, marks READY
return m, store
}
func TestSubmit_WritesPasteAndSubmit_ThenBusy(t *testing.T) {
fp := &fakePTY{}
m, store := newMgr(t, fp)
id, err := m.Submit("hello\nworld", "https://cb/x")
require.NoError(t, err)
require.Equal(t, "turn-1", id)
w := string(fp.bytes())
require.Contains(t, w, "\x1b[200~hello\nworld\x1b[201~") // bracketed paste
require.Contains(t, w, "\r") // submit
rec, _ := store.Get("turn-1")
require.Equal(t, turn.Running, rec.State)
// second submit while busy -> ErrBusy
_, err = m.Submit("again", "")
require.ErrorIs(t, err, session.ErrBusy)
}
func TestComplete_MarksDoneAndFiresCallback(t *testing.T) {
fp := &fakePTY{}
m, store := newMgr(t, fp)
var got *turn.Record
m.OnTurnDone = func(r *turn.Record) { got = r }
_, _ = m.Submit("hi", "https://cb/x")
require.NoError(t, m.Complete(session.HookResult{FinalText: "PONG", StopReason: "end_turn"}))
rec, _ := store.Get("turn-1")
require.Equal(t, turn.Complete, rec.State)
require.Equal(t, "PONG", rec.FinalText)
require.NotNil(t, got)
require.Equal(t, "https://cb/x", got.CallbackURL)
// now idle again -> next submit allowed
_, err := m.Submit("next", "")
require.NoError(t, err)
}
func TestTurnTimeout_FailsAndFiresCallback(t *testing.T) {
fp := &fakePTY{}
m, store := newMgr(t, fp)
done := make(chan *turn.Record, 1)
m.OnTurnDone = func(r *turn.Record) { done <- r }
_, _ = m.Submit("hi", "https://cb/x")
select {
case r := <-done:
require.Equal(t, turn.Failed, r.State)
case <-time.After(time.Second):
t.Fatal("timeout did not fire")
}
rec, _ := store.Get("turn-1")
require.Equal(t, turn.Failed, rec.State)
}
func TestStart_RealPTYWithCat(t *testing.T) {
// integration-ish: drive /bin/cat under a real PTY, confirm bytes flow.
store := turn.NewStore()
m := session.New(session.Config{ClaudePath: "/bin/cat", TurnTimeout: time.Second, SubmitSeq: session.DefaultSubmitSeq, BootTimeout: time.Second},
store, metrics.New(prometheus.NewRegistry()),
slog.New(slog.NewTextHandler(io.Discard, nil)),
time.Now, func() string { return "t" })
require.NoError(t, m.Start(context.Background()))
require.NoError(t, m.Shutdown(context.Background()))
}
- Step 2: Run, verify it fails
Run: go test ./internal/session/ -run TestSubmit -v Expected: FAIL (undefined session).
- Step 3: Implement
pty.go(process + PTY seam)
package session
import (
"fmt"
"io"
"os"
"os/exec"
"github.com/creack/pty"
)
// ptyWriter is the seam the Manager writes turns into. Real impl is a PTY
// master; tests substitute a fake.
type ptyWriter interface {
io.Writer
Close() error
}
type claudeProc struct {
cmd *exec.Cmd
ptmx *os.File
}
func spawnClaude(cfg Config) (*claudeProc, error) {
args := cfg.claudeArgs()
cmd := exec.Command(cfg.ClaudePath, args...)
cmd.Dir = cfg.Workspace
cmd.Env = cfg.Env
ptmx, err := pty.Start(cmd)
if err != nil {
return nil, fmt.Errorf("pty start claude: %w", err)
}
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: 50, Cols: 200})
return &claudeProc{cmd: cmd, ptmx: ptmx}, nil
}
func (c *claudeProc) Write(p []byte) (int, error) { return c.ptmx.Write(p) }
func (c *claudeProc) Close() error { return c.ptmx.Close() }
- Step 4: Implement
session.go(state machine)
// Package session supervises one interactive claude process over a PTY and
// turns API submissions into typed turns, correlating Stop-hook callbacks.
package session
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"time"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/metrics"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
)
// DefaultSubmitSeq wraps a message in bracketed paste, then submits with CR.
// Confirmed/overridden by the Task-1 spike.
var DefaultSubmitSeq = SubmitSequence{PasteStart: "\x1b[200~", PasteEnd: "\x1b[201~", Submit: "\r"}
type SubmitSequence struct{ PasteStart, PasteEnd, Submit string }
func (s SubmitSequence) encode(text string) []byte {
return []byte(s.PasteStart + text + s.PasteEnd + s.Submit)
}
var ErrBusy = errors.New("session busy")
type Config struct {
ClaudePath string
Workspace string
Env []string
Model string
TurnTimeout time.Duration
BootTimeout time.Duration
SubmitSeq SubmitSequence
}
// claudeArgs builds the interactive launch flags. Per the Task-1 spike, we
// pass NO --permission-mode / --dangerously-skip-permissions flag: bypass is
// configured via settings.defaultMode (bootstrap), and the flag would trigger
// an extra "Bypass Permissions" warning dialog. MCP comes from the cwd
// .mcp.json + enableAllProjectMcpServers, so no --mcp-config flag either.
func (c Config) claudeArgs() []string {
args := []string{}
if c.Model != "" {
args = append(args, "--model", c.Model)
}
return args
}
// HookResult is the payload cc-stop-hook POSTs to the internal endpoint.
type HookResult struct {
SessionID string `json:"sessionId"`
FinalText string `json:"finalText"`
ResultJSON json.RawMessage `json:"resultJson,omitempty"`
Usage json.RawMessage `json:"usage,omitempty"`
StopReason string `json:"stopReason"`
}
type State string
const (
Booting State = "booting"
Ready State = "ready"
Busy State = "busy"
Dead State = "dead"
)
type Snapshot struct {
State State `json:"state"`
TurnsCompleted int `json:"turnsCompleted"`
Model string `json:"model"`
Repo string `json:"repo"`
}
type Manager struct {
cfg Config
store *turn.Store
m *metrics.Metrics
log *slog.Logger
now func() time.Time
newID func() string
OnTurnDone func(*turn.Record)
mu sync.Mutex
w ptyWriter
proc *claudeProc
state State
current string // in-flight turn id, "" when idle
currentStarted time.Time
timer *time.Timer
turnsCompleted int
transcriptPath string
}
func New(cfg Config, store *turn.Store, m *metrics.Metrics, log *slog.Logger, now func() time.Time, newID func() string) *Manager {
if cfg.BootTimeout <= 0 {
cfg.BootTimeout = 60 * time.Second
}
return &Manager{cfg: cfg, store: store, m: m, log: log, now: now, newID: newID, state: Booting}
}
// SetWriterForTest injects a writer and marks the session READY. Test-only.
func (mgr *Manager) SetWriterForTest(w ptyWriter) {
mgr.mu.Lock()
defer mgr.mu.Unlock()
mgr.w = w
mgr.state = Ready
}
// Start spawns claude, drains its PTY output, and marks READY after boot.
func (mgr *Manager) Start(ctx context.Context) error {
proc, err := spawnClaude(mgr.cfg)
if err != nil {
return err
}
mgr.mu.Lock()
mgr.proc, mgr.w = proc, proc
mgr.mu.Unlock()
go func() { _, _ = io.Copy(io.Discard, proc.ptmx) }() // drain TUI; debug ring buffer is a later enhancement
go mgr.watch(proc)
// Readiness: spike-confirmed heuristic. Provisional: bounded boot delay.
time.Sleep(minDuration(2*time.Second, mgr.cfg.BootTimeout))
mgr.mu.Lock()
if mgr.state == Booting {
mgr.state = Ready
}
mgr.mu.Unlock()
mgr.log.Info("session ready")
return nil
}
func (mgr *Manager) watch(proc *claudeProc) {
err := proc.cmd.Wait()
mgr.mu.Lock()
mgr.state = Dead
mgr.mu.Unlock()
mgr.m.ClaudeRestarts.Inc()
mgr.log.Error("claude exited", "err", err)
}
func (mgr *Manager) Submit(text, callbackURL string) (string, error) {
mgr.mu.Lock()
defer mgr.mu.Unlock()
if mgr.state == Dead {
return "", fmt.Errorf("session dead")
}
if mgr.state == Booting {
return "", fmt.Errorf("session not ready")
}
if mgr.current != "" {
return "", ErrBusy
}
id := mgr.newID()
now := mgr.now()
mgr.store.Create(id, text, callbackURL, now)
if _, err := mgr.w.Write(mgr.cfg.SubmitSeq.encode(text)); err != nil {
_ = mgr.store.Fail(id, fmt.Sprintf("write pty: %v", err), now)
return "", fmt.Errorf("write pty: %w", err)
}
mgr.current, mgr.currentStarted, mgr.state = id, now, Busy
mgr.m.TurnInFlight.Set(1)
mgr.timer = time.AfterFunc(mgr.cfg.TurnTimeout, func() { mgr.failTimeout(id) })
mgr.log.Info("turn submitted", "turn_id", id)
return id, nil
}
// Complete is invoked from the internal endpoint when a Stop hook fires.
func (mgr *Manager) Complete(r HookResult) error {
mgr.mu.Lock()
id := mgr.current
if id == "" {
mgr.mu.Unlock()
return fmt.Errorf("no in-flight turn")
}
if mgr.timer != nil {
mgr.timer.Stop()
}
if r.SessionID != "" {
// transcript path can be derived elsewhere; session id recorded in logs
}
now := mgr.now()
_ = mgr.store.Complete(id, r.FinalText, r.ResultJSON, r.Usage, r.StopReason, now)
mgr.clearCurrentLocked()
mgr.m.HookReceived.Inc()
mgr.m.TurnsTotal.WithLabelValues("complete").Inc()
mgr.m.TurnDuration.Observe(now.Sub(mgr.currentStarted).Seconds())
rec, _ := mgr.store.Get(id)
mgr.mu.Unlock()
mgr.log.Info("turn complete", "turn_id", id, "duration_ms", now.Sub(rec.StartedAt).Milliseconds())
mgr.fireDone(rec)
return nil
}
func (mgr *Manager) failTimeout(id string) {
mgr.mu.Lock()
if mgr.current != id {
mgr.mu.Unlock()
return
}
now := mgr.now()
_ = mgr.store.Fail(id, "turn timed out", now)
mgr.clearCurrentLocked()
mgr.m.TurnsTotal.WithLabelValues("failed").Inc()
rec, _ := mgr.store.Get(id)
mgr.mu.Unlock()
mgr.log.Warn("turn timed out", "turn_id", id)
mgr.fireDone(rec)
}
func (mgr *Manager) clearCurrentLocked() {
mgr.current = ""
mgr.turnsCompleted++
mgr.state = Ready
mgr.m.TurnInFlight.Set(0)
}
func (mgr *Manager) fireDone(rec *turn.Record) {
if mgr.OnTurnDone != nil && rec != nil {
mgr.OnTurnDone(rec)
}
}
func (mgr *Manager) Snapshot() Snapshot {
mgr.mu.Lock()
defer mgr.mu.Unlock()
return Snapshot{State: mgr.state, TurnsCompleted: mgr.turnsCompleted, Model: mgr.cfg.Model, Repo: ""}
}
func (mgr *Manager) Alive() bool {
mgr.mu.Lock()
defer mgr.mu.Unlock()
return mgr.state != Dead && mgr.state != Booting
}
func (mgr *Manager) TranscriptPath() string {
mgr.mu.Lock()
defer mgr.mu.Unlock()
return mgr.transcriptPath
}
func (mgr *Manager) Shutdown(ctx context.Context) error {
mgr.mu.Lock()
w, proc := mgr.w, mgr.proc
mgr.state = Dead
mgr.mu.Unlock()
if w != nil {
_, _ = w.Write([]byte("\x03")) // Ctrl-C
_ = w.Close()
}
if proc != nil {
_ = proc.cmd.Process.Kill()
}
return nil
}
func minDuration(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
Spike note: replace the
time.Sleepreadiness inStartwith the spike-confirmed prompt-ready signal if one exists; and updateDefaultSubmitSeqif the captured submit bytes differ.
- Step 5: Run the session tests, then commit
Run: go test ./internal/session/ -race -count=1 Expected: PASS (including the /bin/cat real-PTY smoke).
git add internal/session && git commit -m "feat: session manager (PTY spawn, submit, complete, timeout)"
Task 8: cc-stop-hook binary — parse transcript, POST result¶
Files: - Create: cmd/cc-stop-hook/main.go, hook.go, transcript.go - Test: cmd/cc-stop-hook/transcript_test.go, hook_test.go - Uses fixtures from Task 1 (cmd/cc-stop-hook/testdata/).
- Step 1: Write the failing test (against the real captured transcript)
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestLastAssistantText_FromRealTranscript(t *testing.T) {
path := filepath.Join("testdata", "transcript.jsonl")
if _, err := os.Stat(path); err != nil {
t.Skip("spike fixture missing; run Task 1")
}
text, usage, err := lastAssistantText(path)
require.NoError(t, err)
require.NotEmpty(t, text)
_ = usage // usage may be present; type is json.RawMessage
}
func TestBuildResult_FromHookPayload(t *testing.T) {
payload, err := os.ReadFile(filepath.Join("testdata", "hook_payload.json"))
if err != nil {
t.Skip("spike fixture missing; run Task 1")
}
res, err := buildResult(payload, "/nonexistent/result.json")
require.NoError(t, err)
require.NotEmpty(t, res.FinalText)
}
Add a focused synthetic transcript test so the parser is covered even without the spike fixture:
func TestLastAssistantText_Synthetic(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
lines := `{"type":"user","message":{"content":[{"type":"text","text":"hi"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"first"}],"usage":{"output_tokens":1}}}
{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"hmm"},{"type":"text","text":"final answer"}],"usage":{"output_tokens":2}}}
`
require.NoError(t, os.WriteFile(p, []byte(lines), 0o644))
text, usage, err := lastAssistantText(p)
require.NoError(t, err)
require.Equal(t, "final answer", text)
require.JSONEq(t, `{"output_tokens":2}`, string(usage))
}
- Step 2: Run, verify it fails
Run: go test ./cmd/cc-stop-hook/ -run TestLastAssistantText_Synthetic -v Expected: FAIL (undefined lastAssistantText).
- Step 3: Implement
transcript.go
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
type assistantLine struct {
Type string `json:"type"`
Message struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage json.RawMessage `json:"usage"`
} `json:"message"`
}
// lastAssistantText returns the concatenated text blocks of the final
// assistant line in a JSONL transcript, plus its usage object.
func lastAssistantText(path string) (string, json.RawMessage, error) {
f, err := os.Open(path)
if err != nil {
return "", nil, fmt.Errorf("open transcript: %w", err)
}
defer func() { _ = f.Close() }()
var lastText string
var lastUsage json.RawMessage
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1024*1024), 16*1024*1024)
for sc.Scan() {
line := sc.Bytes()
var al assistantLine
if err := json.Unmarshal(line, &al); err != nil || al.Type != "assistant" {
continue
}
text := ""
for _, c := range al.Message.Content {
if c.Type == "text" {
text += c.Text
}
}
if text != "" {
lastText, lastUsage = text, al.Message.Usage
}
}
if err := sc.Err(); err != nil {
return "", nil, fmt.Errorf("scan transcript: %w", err)
}
return lastText, lastUsage, nil
}
- Step 4: Implement
hook.goandmain.go
// hook.go
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/session"
)
// hookPayload mirrors the real Stop-hook payload (Task-1 spike, v2.1.162).
// `last_assistant_message` carries the final text directly; there is NO
// `stop_reason` in the payload (it lives in the transcript).
type hookPayload struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
LastAssistantMessage string `json:"last_assistant_message"`
}
// buildResult assembles the HookResult. FinalText comes from the payload's
// last_assistant_message (authoritative); the transcript is read only for
// `usage` (and as a fallback for text). Folds in /workspace/result.json if
// the agent wrote one.
func buildResult(payload []byte, resultJSONPath string) (session.HookResult, error) {
var hp hookPayload
if err := json.Unmarshal(payload, &hp); err != nil {
return session.HookResult{}, fmt.Errorf("parse hook payload: %w", err)
}
res := session.HookResult{SessionID: hp.SessionID, FinalText: hp.LastAssistantMessage}
if hp.TranscriptPath != "" {
if text, usage, err := lastAssistantText(hp.TranscriptPath); err == nil {
res.Usage = usage
if res.FinalText == "" {
res.FinalText = text
}
}
}
if b, err := os.ReadFile(resultJSONPath); err == nil && json.Valid(b) {
res.ResultJSON = b
}
return res, nil
}
// main.go — pure side effect: read stdin payload, POST result, always exit 0.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "cc-stop-hook:", err)
}
os.Exit(0) // never block or alter claude
}
func run() error {
payload, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
internalURL := envOr("CCW_INTERNAL_URL", "http://127.0.0.1:8090/internal/turn-complete")
resultPath := envOr("CCW_RESULT_JSON", "/workspace/result.json")
res, err := buildResult(payload, resultPath)
if err != nil {
return err
}
body, err := json.Marshal(res)
if err != nil {
return fmt.Errorf("marshal result: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, internalURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("post result: %w", err)
}
defer func() { _ = resp.Body.Close() }()
return nil
}
func envOr(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
- Step 5: Run all hook tests, then commit
Run: go test ./cmd/cc-stop-hook/ -race -count=1 -> PASS (synthetic always; fixture tests run if Task 1 fixtures present).
git add cmd/cc-stop-hook && git commit -m "feat: cc-stop-hook parses transcript and posts turn result"
Task 9: httpapi — handlers, OIDC, internal endpoint¶
Files: - Create: internal/httpapi/api.go, messages.go, session.go, internal.go - Test: internal/httpapi/messages_test.go, internal_test.go
Handlers depend on a SessionController interface so they test with a fake.
- Step 1: Write the failing test
package httpapi_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/httpapi"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/session"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
)
type fakeCtl struct {
submitID string
submitErr error
completed session.HookResult
}
func (f *fakeCtl) Submit(text, cb string) (string, error) { return f.submitID, f.submitErr }
func (f *fakeCtl) Complete(r session.HookResult) error { f.completed = r; return nil }
func (f *fakeCtl) Snapshot() session.Snapshot { return session.Snapshot{State: session.Ready} }
func (f *fakeCtl) TranscriptPath() string { return "" }
func (f *fakeCtl) Alive() bool { return true }
func (f *fakeCtl) Shutdown(context.Context) error { return nil }
func newAPI(ctl httpapi.SessionController, store *turn.Store) *httpapi.API {
return httpapi.New(httpapi.Deps{Ctl: ctl, Store: store}) // Verifier nil -> public router skips OIDC in test mode
}
func TestPostMessage_202(t *testing.T) {
store := turn.NewStore()
api := newAPI(&fakeCtl{submitID: "turn-9"}, store)
body, _ := json.Marshal(map[string]string{"text": "hi", "callbackUrl": "https://cb/x"})
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
rec := httptest.NewRecorder()
api.TestRouter().ServeHTTP(rec, req)
require.Equal(t, http.StatusAccepted, rec.Code)
require.Contains(t, rec.Body.String(), "turn-9")
}
func TestPostMessage_409WhenBusy(t *testing.T) {
api := newAPI(&fakeCtl{submitErr: session.ErrBusy}, turn.NewStore())
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader([]byte(`{"text":"x"}`)))
rec := httptest.NewRecorder()
api.TestRouter().ServeHTTP(rec, req)
require.Equal(t, http.StatusConflict, rec.Code)
}
func TestGetMessage_404Then200(t *testing.T) {
store := turn.NewStore()
api := newAPI(&fakeCtl{}, store)
req := httptest.NewRequest(http.MethodGet, "/v1/messages/none", nil)
rec := httptest.NewRecorder()
api.TestRouter().ServeHTTP(rec, req)
require.Equal(t, http.StatusNotFound, rec.Code)
store.Create("turn-1", "hi", "", timeZero())
req = httptest.NewRequest(http.MethodGet, "/v1/messages/turn-1", nil)
rec = httptest.NewRecorder()
api.TestRouter().ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
}
func TestInternalTurnComplete_CallsController(t *testing.T) {
ctl := &fakeCtl{}
api := newAPI(ctl, turn.NewStore())
body, _ := json.Marshal(session.HookResult{FinalText: "PONG", StopReason: "end_turn"})
req := httptest.NewRequest(http.MethodPost, "/internal/turn-complete", bytes.NewReader(body))
rec := httptest.NewRecorder()
api.InternalRouter().ServeHTTP(rec, req)
require.Equal(t, http.StatusNoContent, rec.Code)
require.Equal(t, "PONG", ctl.completed.FinalText)
}
var _ = errors.New // keep errors import if unused after edits
Add timeZero() helper (func timeZero() time.Time { return time.Unix(0,0) }, import time).
- Step 2: Run, verify it fails
Run: go test ./internal/httpapi/ -v Expected: FAIL (undefined httpapi).
- Step 3: Implement
api.go
// Package httpapi exposes the wrapper's public (OIDC) and internal (loopback)
// HTTP surfaces.
package httpapi
import (
"context"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/auth"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/session"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
)
// SessionController is the slice of session.Manager the API needs.
type SessionController interface {
Submit(text, callbackURL string) (string, error)
Complete(session.HookResult) error
Snapshot() session.Snapshot
TranscriptPath() string
Alive() bool
Shutdown(context.Context) error
}
type Deps struct {
Ctl SessionController
Store *turn.Store
Verifier *auth.Verifier
Log *slog.Logger
Registry *prometheus.Registry
}
type API struct {
ctl SessionController
store *turn.Store
v *auth.Verifier
log *slog.Logger
reg *prometheus.Registry
}
func New(d Deps) *API {
if d.Log == nil {
d.Log = slog.Default()
}
return &API{ctl: d.Ctl, store: d.Store, v: d.Verifier, log: d.Log, reg: d.Registry}
}
// Router is the public surface: OIDC-gated /v1/* plus open operator endpoints.
func (a *API) Router() http.Handler {
r := chi.NewRouter()
r.Group(func(pr chi.Router) {
if a.v != nil {
pr.Use(auth.Middleware(a.v))
}
a.mountV1(pr)
})
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
r.Get("/readyz", a.readyz)
if a.reg != nil {
r.Handle("/metrics", promhttp.HandlerFor(a.reg, promhttp.HandlerOpts{}))
}
return r
}
// TestRouter is the public surface without OIDC, for handler unit tests.
func (a *API) TestRouter() http.Handler {
r := chi.NewRouter()
a.mountV1(r)
r.Get("/readyz", a.readyz)
return r
}
func (a *API) mountV1(r chi.Router) {
r.Post("/v1/messages", a.postMessage)
r.Get("/v1/messages", a.listMessages)
r.Get("/v1/messages/{turnID}", a.getMessage)
r.Get("/v1/session", a.getSession)
r.Get("/v1/transcript", a.getTranscript)
r.Delete("/v1/session", a.deleteSession)
}
// InternalRouter is the loopback-only surface the Stop hook posts to.
func (a *API) InternalRouter() http.Handler {
r := chi.NewRouter()
r.Post("/internal/turn-complete", a.turnComplete)
return r
}
func (a *API) readyz(w http.ResponseWriter, _ *http.Request) {
if a.ctl.Alive() {
w.WriteHeader(http.StatusOK)
return
}
http.Error(w, "not ready", http.StatusServiceUnavailable)
}
- Step 4: Implement
messages.go
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/session"
)
type postMessageReq struct {
Text string `json:"text"`
CallbackURL string `json:"callbackUrl"`
}
func (a *API) postMessage(w http.ResponseWriter, r *http.Request) {
var req postMessageReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Text == "" {
http.Error(w, "text is required", http.StatusBadRequest)
return
}
id, err := a.ctl.Submit(req.Text, req.CallbackURL)
if errors.Is(err, session.ErrBusy) {
http.Error(w, "session busy", http.StatusConflict)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"turnId": id})
}
func (a *API) listMessages(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, a.store.List())
}
func (a *API) getMessage(w http.ResponseWriter, r *http.Request) {
rec, ok := a.store.Get(chi.URLParam(r, "turnID"))
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, rec)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
- Step 5: Implement
session.goandinternal.go
// session.go
package httpapi
import (
"net/http"
"os"
)
func (a *API) getSession(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, a.ctl.Snapshot())
}
func (a *API) getTranscript(w http.ResponseWriter, _ *http.Request) {
p := a.ctl.TranscriptPath()
if p == "" {
http.Error(w, "no transcript yet", http.StatusNotFound)
return
}
b, err := os.ReadFile(p)
if err != nil {
http.Error(w, "transcript unavailable", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write(b)
}
func (a *API) deleteSession(w http.ResponseWriter, r *http.Request) {
_ = a.ctl.Shutdown(r.Context())
w.WriteHeader(http.StatusAccepted)
}
// internal.go
package httpapi
import (
"encoding/json"
"net/http"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/session"
)
func (a *API) turnComplete(w http.ResponseWriter, r *http.Request) {
var res session.HookResult
if err := json.NewDecoder(r.Body).Decode(&res); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
if err := a.ctl.Complete(res); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.WriteHeader(http.StatusNoContent)
}
- Step 6: Run all httpapi tests, then commit
Run: go test ./internal/httpapi/ -race -count=1 -> PASS
git add internal/httpapi && git commit -m "feat: httpapi handlers, OIDC gate, loopback internal endpoint"
Task 10: cmd/wrapper — config, app wiring, main¶
Files: - Create: cmd/wrapper/config.go, app.go, main.go - Test: cmd/wrapper/config_test.go
- Step 1: Write the failing config test
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestLoadConfig_Defaults(t *testing.T) {
cfg, err := loadConfig(nil)
require.NoError(t, err)
require.Equal(t, ":8080", cfg.HTTPAddr)
require.Equal(t, "127.0.0.1:8090", cfg.InternalAddr)
require.Equal(t, "tatara-claude-code-wrapper", cfg.OIDCAudience)
require.Equal(t, "bypassPermissions", cfg.PermissionMode)
require.Equal(t, 1800, cfg.TurnTimeoutSeconds)
require.Equal(t, 3, cfg.WebhookRetries)
}
func TestLoadConfig_EnvOverride(t *testing.T) {
t.Setenv("HTTP_ADDR", ":9000")
t.Setenv("TURN_TIMEOUT_SECONDS", "42")
cfg, err := loadConfig(nil)
require.NoError(t, err)
require.Equal(t, ":9000", cfg.HTTPAddr)
require.Equal(t, 42, cfg.TurnTimeoutSeconds)
}
- Step 2: Run, verify it fails
Run: go test ./cmd/wrapper/ -run TestLoadConfig -v Expected: FAIL (undefined loadConfig).
- Step 3: Implement
config.go
package main
import (
"flag"
"fmt"
"os"
"strconv"
)
type config struct {
HTTPAddr string
InternalAddr string
OIDCIssuer string
OIDCAudience string
LogLevel string
Model string
PermissionMode string
RepoURL string
RepoBranch string
DefaultCallbackURL string
TurnTimeoutSeconds int
BootTimeoutSeconds int
WebhookRetries int
Workspace string
HomeDir string
ClaudePath string
HookPath string
GlobalClaudeMdPath string
ProjectClaudeMdPath string
MCPBasePath string
MCPOverlayDir string
SkillsSrcDirs string // colon-separated
AllowedToolsPath string
}
func loadConfig(args []string) (config, error) {
ti, err := envIntOr("TURN_TIMEOUT_SECONDS", 1800)
if err != nil {
return config{}, err
}
bt, err := envIntOr("BOOT_TIMEOUT_SECONDS", 60)
if err != nil {
return config{}, err
}
wr, err := envIntOr("WEBHOOK_RETRIES", 3)
if err != nil {
return config{}, err
}
cfg := config{
HTTPAddr: envOr("HTTP_ADDR", ":8080"),
InternalAddr: envOr("INTERNAL_ADDR", "127.0.0.1:8090"),
OIDCIssuer: envOr("OIDC_ISSUER", "https://auth.szymonrichert.pl/realms/master"),
OIDCAudience: envOr("OIDC_AUDIENCE", "tatara-claude-code-wrapper"),
LogLevel: envOr("LOG_LEVEL", "info"),
Model: envOr("MODEL", ""),
PermissionMode: envOr("PERMISSION_MODE", "bypassPermissions"),
RepoURL: envOr("REPO_URL", ""),
RepoBranch: envOr("REPO_BRANCH", ""),
DefaultCallbackURL: envOr("DEFAULT_CALLBACK_URL", ""),
TurnTimeoutSeconds: ti,
BootTimeoutSeconds: bt,
WebhookRetries: wr,
Workspace: envOr("WORKSPACE", "/workspace"),
HomeDir: envOr("HOME_DIR", os.Getenv("HOME")),
ClaudePath: envOr("CLAUDE_PATH", "claude"),
HookPath: envOr("HOOK_PATH", "/usr/local/bin/cc-stop-hook"),
GlobalClaudeMdPath: envOr("GLOBAL_CLAUDE_MD_PATH", "/etc/wrapper/global-claude.md"),
ProjectClaudeMdPath: envOr("PROJECT_CLAUDE_MD_PATH", "/etc/wrapper/project-claude.md"),
MCPBasePath: envOr("MCP_BASE_PATH", "/etc/wrapper/mcp-base.json"),
MCPOverlayDir: envOr("MCP_OVERLAY_DIR", "/etc/wrapper/mcp.d"),
SkillsSrcDirs: envOr("SKILLS_SRC_DIRS", "/templates/skills:/etc/wrapper/skills"),
AllowedToolsPath: envOr("ALLOWED_TOOLS_PATH", "/etc/wrapper/allowed-tools.txt"),
}
fs := flag.NewFlagSet("wrapper", flag.ContinueOnError)
fs.StringVar(&cfg.HTTPAddr, "http-addr", cfg.HTTPAddr, "public HTTP listen address")
fs.StringVar(&cfg.InternalAddr, "internal-addr", cfg.InternalAddr, "loopback internal listen address")
if err := fs.Parse(args); err != nil {
return config{}, err
}
return cfg, nil
}
func envOr(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
func envIntOr(k string, def int) (int, error) {
v, ok := os.LookupEnv(k)
if !ok {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("env %s: %w", k, err)
}
return n, nil
}
- Step 4: Implement
app.go(wiring) andmain.go
// app.go
package main
import (
"context"
"log/slog"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/auth"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/bootstrap"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/httpapi"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/metrics"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/obs"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/session"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/turn"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/webhook"
)
type app struct {
log *slog.Logger
pub *http.Server
internal *http.Server
sess *session.Manager
}
func newApp(ctx context.Context, cfg config) (*app, error) {
log := obs.NewLogger(os.Stdout, parseLevel(cfg.LogLevel))
reg := obs.PromRegistry()
m := metrics.New(reg)
if err := bootstrap.Render(buildBootstrapParams(cfg), gitRunner(cfg.Workspace)); err != nil {
return nil, err
}
store := turn.NewStore()
sess := session.New(session.Config{
ClaudePath: cfg.ClaudePath, Workspace: cfg.Workspace,
Env: claudeEnv(cfg),
Model: cfg.Model,
TurnTimeout: time.Duration(cfg.TurnTimeoutSeconds) * time.Second,
BootTimeout: time.Duration(cfg.BootTimeoutSeconds) * time.Second,
SubmitSeq: session.DefaultSubmitSeq,
}, store, m, log, time.Now, newTurnID)
sender := webhook.New(webhook.Config{Retries: cfg.WebhookRetries}, m, log)
defaultCB := cfg.DefaultCallbackURL
sess.OnTurnDone = func(rec *turn.Record) {
url := rec.CallbackURL
if url == "" {
url = defaultCB
}
sender.Deliver(context.Background(), url, rec)
}
if err := sess.Start(ctx); err != nil {
return nil, err
}
var verifier *auth.Verifier
v, err := auth.NewVerifier(ctx, auth.Config{Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience})
if err != nil {
return nil, err
}
verifier = v
api := httpapi.New(httpapi.Deps{Ctl: sess, Store: store, Verifier: verifier, Log: log, Registry: reg})
return &app{
log: log,
sess: sess,
pub: &http.Server{Addr: cfg.HTTPAddr, Handler: api.Router(), ReadHeaderTimeout: 10 * time.Second},
internal: &http.Server{Addr: cfg.InternalAddr, Handler: api.InternalRouter(), ReadHeaderTimeout: 10 * time.Second},
}, nil
}
func (a *app) run() error {
errCh := make(chan error, 2)
go func() { errCh <- a.internal.ListenAndServe() }()
go func() { errCh <- a.pub.ListenAndServe() }()
return <-errCh
}
func (a *app) shutdown(ctx context.Context) error {
_ = a.sess.Shutdown(ctx)
_ = a.internal.Shutdown(ctx)
return a.pub.Shutdown(ctx)
}
func buildBootstrapParams(cfg config) bootstrap.Params {
return bootstrap.Params{
HomeDir: cfg.HomeDir, Workspace: cfg.Workspace,
GlobalClaudeMd: readFileOrEmpty(cfg.GlobalClaudeMdPath),
ProjectClaudeMd: readFileOrEmpty(cfg.ProjectClaudeMdPath),
BaseMCP: readBytesOrDefault(cfg.MCPBasePath, []byte(`{"mcpServers":{}}`)),
MCPOverlayDir: cfg.MCPOverlayDir,
SkillsSrc: strings.Split(cfg.SkillsSrcDirs, ":"),
HookCommand: cfg.HookPath,
AllowedTools: readLines(cfg.AllowedToolsPath),
EnableAllMCP: true,
PermissionMode: cfg.PermissionMode,
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
RepoURL: cfg.RepoURL, RepoBranch: cfg.RepoBranch,
}
}
// helpers in app.go (same file or a small util.go)
func parseLevel(s string) slog.Level {
switch strings.ToLower(s) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func claudeEnv(cfg config) []string {
env := append(os.Environ(), "TERM=xterm-256color")
if cfg.HomeDir != "" {
env = append(env, "HOME="+cfg.HomeDir)
}
return env
}
func gitRunner(dir string) bootstrap.GitRunner {
return func(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %v: %v: %w", args, string(out), err)
}
return nil
}
}
func readFileOrEmpty(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return string(b)
}
func readBytesOrDefault(p string, def []byte) []byte {
b, err := os.ReadFile(p)
if err != nil {
return def
}
return b
}
func readLines(p string) []string {
b, err := os.ReadFile(p)
if err != nil {
return nil
}
var out []string
for _, ln := range strings.Split(string(b), "\n") {
if s := strings.TrimSpace(ln); s != "" {
out = append(out, s)
}
}
return out
}
func newTurnID() string { return "turn-" + strconv.FormatInt(time.Now().UnixNano(), 36) }
(Add imports: os/exec, fmt, strconv to app.go.)
// main.go
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/szymonrychu/tatara-claude-code-wrapper/internal/version"
)
func run(ctx context.Context, args []string) error {
cfg, err := loadConfig(args)
if err != nil {
return err
}
a, err := newApp(ctx, cfg)
if err != nil {
return err
}
a.log.Info("starting", "version", version.Version, "addr", cfg.HTTPAddr)
errCh := make(chan error, 1)
go func() { errCh <- a.run() }()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
select {
case err := <-errCh:
return err
case <-sig:
case <-ctx.Done():
}
a.log.Info("shutdown")
sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return a.shutdown(sctx)
}
func main() {
if err := run(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
- Step 5: Run config test + build, then commit
Run: go test ./cmd/wrapper/ -run TestLoadConfig -count=1 && go build ./... Expected: PASS + clean build.
Task 11: end-to-end integration test with a stub claude¶
Drives the FULL loop with no Anthropic key: a stub "claude" reads the PTY, appends an assistant line to a transcript, and invokes the real built cc-stop-hook, which POSTs to the running internal server.
Files: - Create: cmd/wrapper/testdata/stubclaude/main.go - Test: cmd/wrapper/integration_test.go (build tag integration)
- Step 1: Write the stub claude
//go:build ignore
// stub claude: reads bracketed-paste blocks from stdin (the PTY slave); for
// each submitted message, appends an assistant line to $STUB_TRANSCRIPT and
// runs $STUB_HOOK with a synthetic hook payload on its stdin.
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
func main() {
tp := os.Getenv("STUB_TRANSCRIPT")
hook := os.Getenv("STUB_HOOK")
fmt.Println("stub-claude ready") // readiness marker
sc := bufio.NewScanner(os.Stdin)
for sc.Scan() {
line := sc.Text()
msg := strings.TrimSuffix(strings.TrimPrefix(line, "\x1b[200~"), "\x1b[201~")
if msg == "" {
continue
}
f, _ := os.OpenFile(tp, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
al := map[string]any{"type": "assistant", "message": map[string]any{
"content": []map[string]any{{"type": "text", "text": "echo:" + msg}},
"usage": map[string]any{"output_tokens": 1}}}
b, _ := json.Marshal(al)
_, _ = f.Write(append(b, '\n'))
_ = f.Close()
payload, _ := json.Marshal(map[string]any{
"session_id": "stub", "transcript_path": tp, "stop_reason": "end_turn"})
c := exec.Command(hook)
c.Stdin = strings.NewReader(string(payload))
c.Env = os.Environ()
_ = c.Run()
}
}
- Step 2: Write the integration test
//go:build integration
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestEndToEnd_SubmitTurn_GetResult(t *testing.T) {
dir := t.TempDir()
// build stub claude + cc-stop-hook
stub := filepath.Join(dir, "claude")
require.NoError(t, exec.Command("go", "build", "-o", stub, "./testdata/stubclaude").Run())
hook := filepath.Join(dir, "cc-stop-hook")
require.NoError(t, exec.Command("go", "build", "-o", hook, "../cc-stop-hook").Run())
transcript := filepath.Join(dir, "t.jsonl")
t.Setenv("CLAUDE_PATH", stub)
t.Setenv("HOOK_PATH", hook)
t.Setenv("WORKSPACE", dir)
t.Setenv("HOME_DIR", dir)
t.Setenv("HTTP_ADDR", "127.0.0.1:18080")
t.Setenv("INTERNAL_ADDR", "127.0.0.1:18090")
t.Setenv("STUB_TRANSCRIPT", transcript)
t.Setenv("STUB_HOOK", hook)
t.Setenv("CCW_INTERNAL_URL", "http://127.0.0.1:18090/internal/turn-complete")
t.Setenv("OIDC_ISSUER", "") // skip OIDC: see note below
go func() { _ = run(context.Background(), nil) }()
requireUp(t, "http://127.0.0.1:18080/readyz")
body, _ := json.Marshal(map[string]string{"text": "hello"})
resp, err := http.Post("http://127.0.0.1:18080/v1/messages", "application/json", bytes.NewReader(body))
require.NoError(t, err)
require.Equal(t, http.StatusAccepted, resp.StatusCode)
var pm struct{ TurnID string `json:"turnId"` }
require.NoError(t, json.NewDecoder(resp.Body).Decode(&pm))
require.Eventually(t, func() bool {
r, err := http.Get("http://127.0.0.1:18080/v1/messages/" + pm.TurnID)
if err != nil || r.StatusCode != 200 {
return false
}
var rec struct {
State string `json:"state"`
FinalText string `json:"finalText"`
}
_ = json.NewDecoder(r.Body).Decode(&rec)
return rec.State == "complete" && rec.FinalText == "echo:hello"
}, 10*time.Second, 100*time.Millisecond)
}
func requireUp(t *testing.T, url string) {
t.Helper()
require.Eventually(t, func() bool {
r, err := http.Get(url)
return err == nil && r.StatusCode == 200
}, 10*time.Second, 100*time.Millisecond)
}
OIDC in integration:
newAppcurrently always builds a verifier. Add a guard so an emptyOIDCIssuerskips OIDC (verifier nil ->Routermounts/v1without the middleware). Make that change inapp.goas part of this task:Production always setsvar verifier *auth.Verifier if cfg.OIDCIssuer != "" { v, err := auth.NewVerifier(ctx, auth.Config{Issuer: cfg.OIDCIssuer, Audience: cfg.OIDCAudience}) if err != nil { return nil, err } verifier = v }OIDC_ISSUER, so the gate is always on in the cluster. Document this in README (OIDC is mandatory in deployment).
- Step 3: Run the integration test
Run: go test ./cmd/wrapper/ -tags integration -run TestEndToEnd -count=1 -v Expected: PASS — turn submitted, stub echoes, hook posts, store shows complete + echo:hello.
- Step 4: Commit
Task 12: Dockerfile (modular, claude bumpable) + bake skill¶
Files: - Create: Dockerfile, templates/skills/handoff/SKILL.md
- Step 1: Bake the default handoff skill
mkdir -p templates/skills/handoff
cp ../../tatara-old/images/agent-claude/skills/handoff/SKILL.md templates/skills/handoff/SKILL.md
(If the old file references spawn-MCP continue_with_handoff, trim those lines — this wrapper has no spawn MCP. Keep the doc-writing guidance.)
- Step 2: Write the Dockerfile
# syntax=docker/dockerfile:1.7
ARG GO_VERSION=1.25
ARG NODE_VERSION=22
ARG CLAUDE_CODE_VERSION=latest
ARG TATARA_CLI_VERSION=latest
# Stage 1: build the Go binaries (cached independently of the claude layer).
FROM golang:${GO_VERSION}-alpine AS go-build
WORKDIR /src
RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
ARG DATE=unknown
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags "-s -w -X github.com/szymonrychu/tatara-claude-code-wrapper/internal/version.Version=${VERSION} -X github.com/szymonrychu/tatara-claude-code-wrapper/internal/version.Commit=${COMMIT} -X github.com/szymonrychu/tatara-claude-code-wrapper/internal/version.Date=${DATE}" \
-o /out/wrapper ./cmd/wrapper && \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/cc-stop-hook ./cmd/cc-stop-hook
# Stage 2: pull the tatara-cli binary at a pinned version.
FROM harbor.szymonrichert.pl/containers/tatara-cli:${TATARA_CLI_VERSION} AS tatara-cli
# Stage 3: runtime — node + claude in their own layer for trivial bumps.
FROM node:${NODE_VERSION}-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# claude lives in its OWN layer: bumping CLAUDE_CODE_VERSION rebuilds only this.
ARG CLAUDE_CODE_VERSION
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} && npm cache clean --force
COPY --from=tatara-cli /usr/local/bin/tatara /usr/local/bin/tatara
COPY --from=go-build /out/wrapper /usr/local/bin/wrapper
COPY --from=go-build /out/cc-stop-hook /usr/local/bin/cc-stop-hook
COPY templates/ /templates/
# non-root, writable HOME + workspace
RUN useradd -m -u 10001 agent && mkdir -p /workspace && chown -R agent:agent /workspace /templates
USER agent
ENV HOME=/home/agent HOME_DIR=/home/agent WORKSPACE=/workspace
WORKDIR /workspace
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/wrapper"]
If the pinned
tatara-cliimage path/tag differs, adjust Stage 2. Confirm the tatara-cli image exists atharbor.szymonrichert.pl/containers/tatara-cli(it ships from phase 2).
- Step 3: Build locally to validate (no push)
Run: make image CLAUDE_CODE_VERSION=latest TATARA_CLI_VERSION=latest Expected: image builds. (If no Docker locally, defer to CI; note it.)
- Step 4: Commit
git add Dockerfile templates && git commit -m "feat: modular Dockerfile (claude in its own layer) + baked handoff skill"
Task 13: Helm chart¶
Files: - Create: charts/tatara-claude-code-wrapper/ via helm create, then trim to: Chart.yaml, values.yaml, templates/_helpers.tpl, configmap.yaml, configmap-files.yaml, secret.yaml, deployment.yaml, service.yaml, networkpolicy.yaml, servicemonitor.yaml, serviceaccount.yaml - Test: charts/tatara-claude-code-wrapper/tests/*.yaml (helm-unittest)
- Step 1: Generate and trim
cd charts && helm create tatara-claude-code-wrapper && cd ..
rm -rf charts/tatara-claude-code-wrapper/templates/{hpa.yaml,ingress.yaml,tests,NOTES.txt}
rm -f charts/tatara-claude-code-wrapper/templates/serviceaccount.yaml # regenerate minimal below if needed
- Step 2:
values.yaml(camelCase scalars; lists/multiline are separate file-shaped values rendered into a mounted ConfigMap)
image:
repository: harbor.szymonrichert.pl/containers/tatara-claude-code-wrapper
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets:
- name: regcred
replicaCount: 1
httpAddr: ":8080"
internalAddr: "127.0.0.1:8090"
oidcIssuer: "https://auth.szymonrichert.pl/realms/master"
oidcAudience: "tatara-claude-code-wrapper"
logLevel: "info"
model: ""
permissionMode: "bypassPermissions"
repoUrl: ""
repoBranch: ""
defaultCallbackUrl: ""
turnTimeoutSeconds: 1800
bootTimeoutSeconds: 60
webhookRetries: 3
# file-shaped inputs rendered into the files ConfigMap, mounted at /etc/wrapper
globalClaudeMd: ""
projectClaudeMd: ""
allowedTools: [] # list -> rendered to allowed-tools.txt, NOT env
extraMcpServers: {} # map name->server json -> rendered into mcp.d/
baseMcp: |
{"mcpServers":{"tatara-memory":{"type":"stdio","command":"tatara","args":["mcp"]}}}
# secrets via external Secret (sops); set existingSecret to override
existingSecret: ""
anthropicApiKeySecret: "tatara-claude-code-wrapper"
anthropicApiKeyKey: "anthropic-api-key"
service:
type: ClusterIP
port: 8080
serviceMonitor:
enabled: true
interval: "30s"
scrapeTimeout: "10s"
networkPolicy:
enabled: true
# namespace label selector for allowed callers (e.g. argo)
callerNamespace: "tatara"
resources: {}
persistence:
enabled: false # PVC for /workspace (survives restart); default emptyDir
size: 5Gi
storageClass: "rook-ceph-block"
- Step 3:
_helpers.tplenvConfig (UPPER_SNAKE keys)
{{- define "tatara-claude-code-wrapper.envConfig" -}}
HTTP_ADDR: {{ .Values.httpAddr | quote }}
INTERNAL_ADDR: {{ .Values.internalAddr | quote }}
OIDC_ISSUER: {{ .Values.oidcIssuer | quote }}
OIDC_AUDIENCE: {{ .Values.oidcAudience | quote }}
LOG_LEVEL: {{ .Values.logLevel | quote }}
MODEL: {{ .Values.model | quote }}
PERMISSION_MODE: {{ .Values.permissionMode | quote }}
REPO_URL: {{ .Values.repoUrl | quote }}
REPO_BRANCH: {{ .Values.repoBranch | quote }}
DEFAULT_CALLBACK_URL: {{ .Values.defaultCallbackUrl | quote }}
TURN_TIMEOUT_SECONDS: {{ .Values.turnTimeoutSeconds | quote }}
BOOT_TIMEOUT_SECONDS: {{ .Values.bootTimeoutSeconds | quote }}
WEBHOOK_RETRIES: {{ .Values.webhookRetries | quote }}
GLOBAL_CLAUDE_MD_PATH: "/etc/wrapper/global-claude.md"
PROJECT_CLAUDE_MD_PATH: "/etc/wrapper/project-claude.md"
MCP_BASE_PATH: "/etc/wrapper/mcp-base.json"
MCP_OVERLAY_DIR: "/etc/wrapper/mcp.d"
SKILLS_SRC_DIRS: "/templates/skills:/etc/wrapper/skills"
ALLOWED_TOOLS_PATH: "/etc/wrapper/allowed-tools.txt"
{{- end -}}
(Keep the standard helm create name/fullname/labels helpers.)
- Step 4:
configmap.yaml(scalars) +configmap-files.yaml(file-shaped)
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "tatara-claude-code-wrapper.fullname" . }}
labels: {{- include "tatara-claude-code-wrapper.labels" . | nindent 4 }}
data:
{{- include "tatara-claude-code-wrapper.envConfig" . | nindent 2 }}
# configmap-files.yaml — multiline/list/map data, mounted as files
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "tatara-claude-code-wrapper.fullname" . }}-files
labels: {{- include "tatara-claude-code-wrapper.labels" . | nindent 4 }}
data:
global-claude.md: |-
{{- .Values.globalClaudeMd | nindent 4 }}
project-claude.md: |-
{{- .Values.projectClaudeMd | nindent 4 }}
mcp-base.json: |-
{{- .Values.baseMcp | nindent 4 }}
allowed-tools.txt: |-
{{- range .Values.allowedTools }}
{{ . }}
{{- end }}
{{- range $name, $server := .Values.extraMcpServers }}
mcp.d__{{ $name }}.json: |-
{{- dict "mcpServers" (dict $name $server) | toJson | nindent 4 }}
{{- end }}
The
mcp.d__<name>.jsonkeys are projected to/etc/wrapper/mcp.d/<name>.jsonvia aitemsmapping in the volume (Step 6), keeping list-shaped MCP data file-based per hard rule 6.
- Step 5:
secret.yaml
{{- if not .Values.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "tatara-claude-code-wrapper.fullname" . }}
labels: {{- include "tatara-claude-code-wrapper.labels" . | nindent 4 }}
type: Opaque
data: {}
{{- end }}
(Real secret values come from a sops/external Secret named per anthropicApiKeySecret; this stub exists so envFrom.secretRef resolves when no external secret is set in non-prod.)
- Step 6:
deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "tatara-claude-code-wrapper.fullname" . }}
labels: {{- include "tatara-claude-code-wrapper.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {{- include "tatara-claude-code-wrapper.selectorLabels" . | nindent 6 }}
template:
metadata:
labels: {{- include "tatara-claude-code-wrapper.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets: {{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: wrapper
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- configMapRef:
name: {{ include "tatara-claude-code-wrapper.fullname" . }}
- secretRef:
name: {{ default (include "tatara-claude-code-wrapper.fullname" .) .Values.existingSecret }}
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.anthropicApiKeySecret }}
key: {{ .Values.anthropicApiKeyKey }}
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet: { path: /readyz, port: http }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: http }
initialDelaySeconds: 10
periodSeconds: 20
volumeMounts:
- name: files
mountPath: /etc/wrapper
- name: workspace
mountPath: /workspace
resources: {{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: files
configMap:
name: {{ include "tatara-claude-code-wrapper.fullname" . }}-files
items:
- { key: global-claude.md, path: global-claude.md }
- { key: project-claude.md, path: project-claude.md }
- { key: mcp-base.json, path: mcp-base.json }
- { key: allowed-tools.txt, path: allowed-tools.txt }
{{- range $name, $server := .Values.extraMcpServers }}
- { key: "mcp.d__{{ $name }}.json", path: "mcp.d/{{ $name }}.json" }
{{- end }}
- name: workspace
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "tatara-claude-code-wrapper.fullname" . }}
{{- else }}
emptyDir: {}
{{- end }}
(Add pvc.yaml gated by .Values.persistence.enabled; standard cnpg-style PVC like tatara-memory's pvc.yaml.)
- Step 7:
service.yaml,networkpolicy.yaml,servicemonitor.yaml,serviceaccount.yaml
Service: ClusterIP exposing port 8080 -> http. NetworkPolicy: ingress to 8080 only from callerNamespace; egress allowed (claude needs the internet + tatara-memory). ServiceMonitor gated by serviceMonitor.enabled, path /metrics, port http. (Model these on tatara-memory's equivalents.)
- Step 8: helm-unittest tests
# tests/configmap_test.yaml
suite: configmap env keys are UPPER_SNAKE
templates:
- configmap.yaml
tests:
- it: renders UPPER_SNAKE scalar keys
asserts:
- isKind: { of: ConfigMap }
- matchRegex: { path: "data.OIDC_AUDIENCE", pattern: "tatara-claude-code-wrapper" }
- exists: { path: "data.TURN_TIMEOUT_SECONDS" }
- notExists: { path: "data.allowedTools" }
# tests/deployment_test.yaml
suite: deployment wiring
templates:
- deployment.yaml
- configmap.yaml
- configmap-files.yaml
- secret.yaml
tests:
- it: mounts files and injects ANTHROPIC_API_KEY
asserts:
- template: deployment.yaml
contains:
path: spec.template.spec.containers[0].volumeMounts
content: { name: files, mountPath: /etc/wrapper }
- template: deployment.yaml
contains:
path: spec.template.spec.containers[0].env
content:
name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef: { name: tatara-claude-code-wrapper, key: anthropic-api-key }
- it: defaults workspace to emptyDir
asserts:
- template: deployment.yaml
exists: { path: "spec.template.spec.volumes[?(@.name=='workspace')].emptyDir" }
# tests/files_configmap_test.yaml
suite: file-shaped config never leaks into env configmap
templates:
- configmap-files.yaml
tests:
- it: renders extra mcp servers as mcp.d files
set:
extraMcpServers:
tasks: { type: stdio, command: /bin/tasks }
asserts:
- exists: { path: "data.mcp\\.d__tasks\\.json" }
- Step 9: Lint + unittest, then commit
git add charts && git commit -m "feat: helm chart (UPPER_SNAKE env CM, file CM for lists/multiline, PVC opt-in)"
Task 14: README, MEMORY, ROADMAP, Chart appVersion¶
Files: - Create/modify: README.md, MEMORY.md, ROADMAP.md, charts/tatara-claude-code-wrapper/Chart.yaml
- Step 1: Chart.yaml version + appVersion = 0.1.0
Set version: 0.1.0 and appVersion: "0.1.0"; description per the goal.
-
Step 2: README documenting: purpose, the PTY/interactive-not-
-pdecision, API endpoints, config table (env + file-shaped), webhook-or-poll, OIDC mandatory in deployment,make bump-claude, the v0.1.0 limitations (claude crash loses context; no resume; 409-on-busy; ClusterIP only). -
Step 3: MEMORY.md seed entries (dated 2026-06-04): the PTY decision and why
-pis forbidden; the spike-captured submit sequence + hook payload shape (referencedocs/spike-findings.md); UPPER_SNAKE ConfigMap convention; claude-in-its-own-layer Dockerfile rationale. -
Step 4: ROADMAP.md with v0.2 items:
claude --resumefrom transcript after crash; turn queueing; ingress + multi-pod router; SSE live streaming; Bedrock/Vertex. -
Step 5: Commit
Task 15: CI wiring (argo) + parent repo bookkeeping¶
Files: - Modify (infra repo): ~/Documents/infra/helmfile/helmfiles/coding/values/argo-events/common.yaml - Modify (parent): ~/Documents/tatara/ROADMAP.md, MEMORY.md, ARCHITECTURE.md
- Step 1: Register the repo in the argo-events registry
Add an entry mirroring the existing tatara-cli/tatara-memory ones so push_tag triggers go-ci + container-build + helm-publish for tatara-claude-code-wrapper (namespace tatara). Follow the exact shape already in that file (read it first; do not invent keys).
- Step 2: Register the GitHub webhook
gh api repos/szymonrychu/tatara-claude-code-wrapper/hooks -X POST \
-f config[url]=<argo-events ingress url> -f config[content_type]=json \
-f events[]=push # match how the siblings registered (see infra MEMORY)
(Exact URL/secret per the existing argo-events EventSource; copy the pattern used for tatara-memory.)
-
Step 3: Update parent docs — flip ROADMAP Phase 4 to
in progress/shippedas appropriate, add a MEMORY entry, and note the component is no longer just "a container image" but a PTY-driven session API. Commit in the PARENT repo (separate from the child repo). -
Step 4: Tag v0.1.0 once CI is green
Expected: argo runs go-ci -> container-build -> helm-publish; image + chart land in harbor. Add the helm release in ~/Documents/infra/helmfile (a tatara-claude-code-wrapper release in the tatara namespace) and helmfile diff before apply.
- Step 5: Keycloak client precondition
Before deploy, ensure the tatara-claude-code-wrapper Keycloak client/audience exists (terraform in ~/Documents/infra/terraform/keycloak, mirror the tatara-memory client). terraform plan then apply. This is a hard precondition for the OIDC gate.
Self-Review¶
Spec coverage: - Persistent multi-turn, one-per-pod, PTY interactive (no -p): Tasks 1, 7. ✓ - Stop hook posts result: Tasks 1, 8, 9 (internal endpoint). ✓ - Async submit + webhook + poll fallback; callbackUrl optional: Tasks 5, 9 (/v1/messages GETs), 10 (default callback). ✓ - Repo optional / both: Task 6 (cloneRepo), Task 10 wiring. ✓ - OIDC bearer: Task 2 (copied auth), Task 9 (middleware), Task 11 (gate guard), Task 15 (Keycloak client). ✓ - Session config baked at pod start, eager boot: Task 10 + Task 13 (env CM). ✓ - Extra MCP servers: Task 6 (mergeMCP), Task 13 (extraMcpServers -> mcp.d files). ✓ - Skills incl. custom: Task 6 (installSkills), Task 12 (baked), Task 13 (SKILLS_SRC_DIRS mount). ✓ - Global + project CLAUDE.md via config: Task 6, Task 13 (file CM). ✓ - Modular container, claude bumpable: Task 12 (own layer + ARG), Makefile bump-claude. ✓ - Observability (ccw_* metrics, slog): Tasks 4, 7, 9. ✓ - Failure modes (claude dies, turn timeout, webhook drop): Tasks 7 (timeout, watch/Dead), 5 (drop). ✓ (claude crash = pod restart, documented; resume is roadmap.) - Hard rule 6 (UPPER_SNAKE env CM, file CM for lists/multiline): Task 13. ✓
Placeholder scan: No TBD/TODO. Provisional values (submit sequence, readiness heuristic) are explicitly bound to the Task-1 spike with "adjust to captured reality" notes — these are de-risking steps, not placeholders.
Type consistency: session.HookResult, turn.Record/turn.Store methods, httpapi.SessionController, bootstrap.Params/GitRunner, webhook.Config/Sender.Deliver, metrics.Metrics fields are defined once (Tasks 3-7) and referenced consistently in Tasks 8-13. Manager.Complete signature matches the SessionController interface and the internal handler.
Execution Notes¶
- Models: implementation subagents sonnet; the merge/integration review opus (hard rule 7). Task 1 (spike) and Task 11 (e2e) warrant opus review.
- Branch flow: worktree off the child repo
main; merge back to childmain; build/deploy only frommain(hard rule 10). - Anthropic key is needed ONLY for Task 1 (spike) and any manual cluster smoke. All unit + integration tests run without it (stub claude).