Initial commit on Forgejo
Some checks failed
CI and releases / Test and vet (push) Failing after 1s
CI and releases / Build release binaries (push) Has been skipped
CI and releases / Publish GitHub release (push) Has been skipped

Fresh repository history for elektrine/tarakan-client hosted at
https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
Maxfield Luke 2026-07-29 04:56:36 -04:00
commit 58b29a7f84
70 changed files with 12994 additions and 0 deletions

View file

@ -0,0 +1,281 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
)
// runClaude streams Claude Code stream-json events so the TUI can show tool
// use (Read, Bash, Task/subagents) while the run is in progress.
func runClaude(ctx context.Context, provider Provider, request Request) (string, error) {
if provider.Path == "" {
return "", ErrUnavailable
}
args := []string{
"-p", securityPrompt(request.Prompt),
"--output-format", "stream-json",
"--verbose",
// Isolated snapshot; unattended tool use for the review run.
"--dangerously-skip-permissions",
}
command := exec.CommandContext(ctx, provider.Path, args...)
command.Dir = request.Directory
command.Env = subprocessEnvironment(os.Environ())
stdout, err := command.StdoutPipe()
if err != nil {
return "", err
}
var stderrBuf bytes.Buffer
command.Stderr = &stderrBuf
if err := command.Start(); err != nil {
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
}
progress := request.Progress
report := func(line string) {
if progress == nil {
return
}
if line = strings.TrimSpace(line); line != "" {
progress(line)
}
}
var (
lastFooter string
footerMu sync.Mutex
)
reportFooter := func(line string) {
if progress == nil {
return
}
footerMu.Lock()
defer footerMu.Unlock()
if line == lastFooter {
return
}
lastFooter = line
progress(line)
}
var (
finalResult string
textBuf strings.Builder
seen = map[string]struct{}{}
)
emit := func(line string) {
for _, part := range strings.Split(line, "\n") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key := normalizeActivityKey(part)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
report(part)
}
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
ev := parseClaudeStreamLine(scanner.Text())
if !ev.ok {
continue
}
if ev.activity != "" {
emit(ev.activity)
}
if ev.footer != "" {
reportFooter(ev.footer)
}
if ev.assistantText != "" {
textBuf.WriteString(ev.assistantText)
}
if ev.finalResult != "" {
finalResult = ev.finalResult
}
}
_ = scanner.Err()
waitErr := command.Wait()
output := strings.TrimSpace(finalResult)
if output == "" {
output = strings.TrimSpace(textBuf.String())
}
if waitErr != nil {
errText := strings.TrimSpace(stderrBuf.String())
if output == "" && errText != "" {
output = errText
}
return output, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
}
return output, nil
}
type claudeStreamEvent struct {
ok bool
activity string
footer string
assistantText string
finalResult string
}
func parseClaudeStreamLine(line string) claudeStreamEvent {
var row map[string]any
if err := json.Unmarshal([]byte(line), &row); err != nil {
return claudeStreamEvent{}
}
typ, _ := row["type"].(string)
switch typ {
case "system":
if sub, _ := row["subtype"].(string); sub == "init" {
return claudeStreamEvent{ok: true, activity: "→ Claude session started"}
}
case "assistant":
msg, _ := row["message"].(map[string]any)
content, _ := msg["content"].([]any)
var acts []string
var texts []string
footer := ""
for _, block := range content {
b, _ := block.(map[string]any)
switch b["type"] {
case "tool_use":
name, _ := b["name"].(string)
input, _ := b["input"].(map[string]any)
if label := formatClaudeTool(name, input); label != "" {
acts = append(acts, "→ "+label)
}
case "text":
if t, _ := b["text"].(string); strings.TrimSpace(t) != "" {
texts = append(texts, t)
footer = "… writing response"
}
case "thinking":
footer = "… thinking"
}
}
return claudeStreamEvent{
ok: true,
activity: strings.Join(acts, "\n"),
footer: footer,
assistantText: strings.Join(texts, ""),
}
case "user":
msg, _ := row["message"].(map[string]any)
content, _ := msg["content"].([]any)
for _, block := range content {
b, _ := block.(map[string]any)
if b["type"] == "tool_result" {
return claudeStreamEvent{ok: true, footer: "… tool finished"}
}
}
case "result":
ev := claudeStreamEvent{ok: true}
if r, _ := row["result"].(string); strings.TrimSpace(r) != "" {
ev.finalResult = r
}
if isErr, _ := row["is_error"].(bool); isErr {
if e, _ := row["error"].(string); e != "" {
ev.activity = "Claude error: " + e
}
}
return ev
case "stream_event":
return claudeStreamEvent{ok: true, footer: "… streaming"}
}
return claudeStreamEvent{}
}
func formatClaudeTool(name string, input map[string]any) string {
name = strings.TrimSpace(name)
if input == nil {
input = map[string]any{}
}
switch name {
case "Read", "read_file":
path := firstString(input, "file_path", "path", "target_file")
if path != "" {
return "Read " + path
}
return "Read file"
case "Bash", "bash", "Shell":
cmd := firstString(input, "command")
desc := firstString(input, "description")
if desc != "" {
return "Shell: " + desc
}
if cmd != "" {
return "Shell: " + truncateRunes(cmd, 80)
}
return "Shell"
case "Grep", "grep":
pat := firstString(input, "pattern")
path := firstString(input, "path")
if pat != "" && path != "" {
return "Grep " + quoteShort(pat) + " in " + path
}
if pat != "" {
return "Grep " + quoteShort(pat)
}
return "Grep"
case "Glob", "glob":
pat := firstString(input, "pattern", "glob_pattern")
if pat != "" {
return "Glob " + pat
}
return "Glob"
case "Edit", "Write", "MultiEdit", "NotebookEdit":
path := firstString(input, "file_path", "path")
if path != "" {
return "Edit " + path
}
return "Edit file"
case "Task", "Agent", "TaskCreate":
desc := firstString(input, "description", "prompt")
sub := firstString(input, "subagent_type", "agent")
if sub != "" && desc != "" {
return "Subagent " + sub + ": " + truncateRunes(desc, 60)
}
if sub != "" {
return "Subagent " + sub
}
if desc != "" {
return "Subagent: " + truncateRunes(desc, 60)
}
return "Subagent"
case "WebSearch", "WebFetch":
q := firstString(input, "query", "url")
if q != "" {
return name + ": " + truncateRunes(q, 60)
}
return name
case "LS", "list_dir":
path := firstString(input, "path", "target_directory")
if path != "" {
return "List " + path
}
return "List directory"
default:
if name == "" {
return ""
}
for _, k := range []string{"file_path", "path", "command", "query", "description", "pattern"} {
if v := firstString(input, k); v != "" {
return name + " " + truncateRunes(v, 60)
}
}
return name
}
}