210 lines
4.0 KiB
Go
210 lines
4.0 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"os/signal"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"g.fc.run/misc/byre/internal/claude"
|
||
|
|
"g.fc.run/misc/byre/internal/mcp"
|
||
|
|
"g.fc.run/misc/byre/internal/report"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
resume := flag.String("resume", "", "claude session id to resume")
|
||
|
|
flag.Parse()
|
||
|
|
|
||
|
|
dir, err := os.Getwd()
|
||
|
|
if err != nil {
|
||
|
|
fatal("getwd", "err", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
gate := &report.Gate{OnReport: printReport}
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
s, err := claude.Start(ctx, claude.Options{
|
||
|
|
Cwd: dir,
|
||
|
|
Resume: *resume,
|
||
|
|
GitName: gitConfig(dir, "user.name"),
|
||
|
|
GitEmail: gitConfig(dir, "user.email"),
|
||
|
|
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{gate.Tool()}}},
|
||
|
|
Hooks: map[string][]claude.HookMatcher{
|
||
|
|
"Stop": {{Func: gate.StopHook}},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
fatal("start claude", "err", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
sigs := make(chan os.Signal, 1)
|
||
|
|
signal.Notify(sigs, os.Interrupt)
|
||
|
|
|
||
|
|
lines := make(chan string)
|
||
|
|
go readLines(lines)
|
||
|
|
|
||
|
|
for {
|
||
|
|
fmt.Print("> ")
|
||
|
|
|
||
|
|
select {
|
||
|
|
case line, ok := <-lines:
|
||
|
|
if !ok {
|
||
|
|
shutdown(s)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(line) == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
gate.Reset()
|
||
|
|
err := s.Send(line)
|
||
|
|
if err != nil {
|
||
|
|
fatal("send", "err", err)
|
||
|
|
}
|
||
|
|
runTurn(ctx, s, sigs)
|
||
|
|
|
||
|
|
case <-sigs:
|
||
|
|
fmt.Println()
|
||
|
|
shutdown(s)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func runTurn(ctx context.Context, s *claude.Session, sigs <-chan os.Signal) {
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case ev, ok := <-s.Events():
|
||
|
|
if !ok {
|
||
|
|
fatal("claude exited")
|
||
|
|
}
|
||
|
|
if printEvent(ev) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
case <-sigs:
|
||
|
|
fmt.Println("\n[interrupting]")
|
||
|
|
err := s.Interrupt(ctx)
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("interrupt", "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func printEvent(ev claude.Event) bool {
|
||
|
|
switch {
|
||
|
|
case ev.Type == "system" && ev.Subtype == "init":
|
||
|
|
info := struct {
|
||
|
|
SessionID string `json:"session_id"`
|
||
|
|
Model string `json:"model"`
|
||
|
|
}{}
|
||
|
|
err := json.Unmarshal(ev.Raw, &info)
|
||
|
|
if err != nil {
|
||
|
|
fatal("parse init", "err", err)
|
||
|
|
}
|
||
|
|
fmt.Printf("[session %s, %s]\n", info.SessionID, info.Model)
|
||
|
|
|
||
|
|
case ev.Type == "assistant":
|
||
|
|
blocks, err := ev.Blocks()
|
||
|
|
if err != nil {
|
||
|
|
fatal("parse assistant", "err", err)
|
||
|
|
}
|
||
|
|
for _, b := range blocks {
|
||
|
|
switch b.Type {
|
||
|
|
case "text":
|
||
|
|
fmt.Println(b.Text)
|
||
|
|
case "tool_use":
|
||
|
|
fmt.Printf("→ %s %s\n", b.Name, oneLine(string(b.Input), 160))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
case ev.Type == "user":
|
||
|
|
blocks, err := ev.Blocks()
|
||
|
|
if err != nil {
|
||
|
|
fatal("parse user", "err", err)
|
||
|
|
}
|
||
|
|
for _, b := range blocks {
|
||
|
|
if b.Type == "tool_result" && b.IsError {
|
||
|
|
fmt.Printf("← error %s\n", oneLine(string(b.Content), 160))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
case ev.Type == "result":
|
||
|
|
res, err := ev.Result()
|
||
|
|
if err != nil {
|
||
|
|
fatal("parse result", "err", err)
|
||
|
|
}
|
||
|
|
fmt.Printf("[%s, %d turns, %.1fs, $%.4f]\n", res.Subtype, res.NumTurns, float64(res.DurationMS)/1000, res.TotalCostUSD)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func printReport(r report.Report) {
|
||
|
|
fmt.Println("── report")
|
||
|
|
sections := []struct {
|
||
|
|
name string
|
||
|
|
items []string
|
||
|
|
}{
|
||
|
|
{"done", r.Done},
|
||
|
|
{"open questions", r.OpenQuestions},
|
||
|
|
{"next steps", r.NextSteps},
|
||
|
|
{"risks", r.Risks},
|
||
|
|
{"deviations", r.Deviations},
|
||
|
|
}
|
||
|
|
for _, sec := range sections {
|
||
|
|
if len(sec.items) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
fmt.Printf(" %s:\n", sec.name)
|
||
|
|
for _, item := range sec.items {
|
||
|
|
fmt.Printf(" • %s\n", item)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func readLines(out chan<- string) {
|
||
|
|
defer close(out)
|
||
|
|
sc := bufio.NewScanner(os.Stdin)
|
||
|
|
for sc.Scan() {
|
||
|
|
out <- sc.Text()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func shutdown(s *claude.Session) {
|
||
|
|
err := s.Close()
|
||
|
|
if err != nil {
|
||
|
|
slog.Warn("claude exit", "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func gitConfig(dir, key string) string {
|
||
|
|
cmd := exec.Command("git", "config", key)
|
||
|
|
cmd.Dir = dir
|
||
|
|
out, err := cmd.Output()
|
||
|
|
if err != nil {
|
||
|
|
fatal("git config", "key", key, "err", err)
|
||
|
|
}
|
||
|
|
return strings.TrimSpace(string(out))
|
||
|
|
}
|
||
|
|
|
||
|
|
func oneLine(s string, max int) string {
|
||
|
|
s = strings.Join(strings.Fields(s), " ")
|
||
|
|
if len(s) > max {
|
||
|
|
return s[:max] + "…"
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func fatal(msg string, args ...any) {
|
||
|
|
slog.Error(msg, args...)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|