diff --git a/README.md b/README.md index 331cae4..fe95a3d 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,35 @@ All runtime config is environment variables. Defaults are in [`backend/internal/ --- +## Local code-generation agent (optional) + +Managed Agents covers most use cases — Anthropic hosts the harness, you just +ship prompts. For the cases where you instead need an agent that runs **on +this machine** (filesystem access, on-prem deployments, off-platform tools, or +swapping in Codex / Aider / OpenHands / Cline) the template ships an opt-in +wrapper around [`teslashibe/codegen-go`](https://github.com/teslashibe/codegen-go). + +```bash +npm install -g @anthropic-ai/claude-code # or your CLI of choice +claude login + +# add to backend/.env (see backend/.env.example for the full block): +echo 'CODEGEN_AGENT=claude-code' >> backend/.env + +# kick the tires from the repo root: +go run ./backend/cmd/codegen-demo "Summarise this directory in one paragraph." +``` + +The wiring lives in [`backend/internal/codegen`](./backend/internal/codegen/codegen.go) — +~100 lines that read `CODEGEN_*` env vars and hand back a +`codegen.Agent`. It is independent of the Managed Agents path; you can use one, +the other, or both side-by-side. See the +[`codegen-go` README](https://github.com/teslashibe/codegen-go#readme) for the +full API and the supported CLI presets (Claude Code, Codex, Aider, OpenHands, +Cline, custom). + +--- + ## Common tasks ```bash diff --git a/backend/.env.example b/backend/.env.example index 4f6dbe2..4d491f0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,19 @@ AGENT_SYSTEM_PROMPT=You are a helpful assistant. AGENT_RUN_RATE_LIMIT=10 AGENT_RUN_RATE_WINDOW_SECONDS=60 +# --- Local code-generation agent (OPTIONAL) -------------------------------- +# Pluggable wrapper around `claude` (and any other prompt-on-stdin coding-agent +# CLI) via github.com/teslashibe/codegen-go. Used by `cmd/codegen-demo` and any +# code that calls `internal/codegen.LoadFromEnv()`. Independent of the +# Anthropic Managed Agents path above; leave commented out if you only use +# Managed Agents. +# CODEGEN_AGENT=claude-code # claude-code (default) | generic +# CODEGEN_MODEL=claude-sonnet-4-5 # optional --model override +# CODEGEN_TIMEOUT=30m # per-run cap +# CODEGEN_MAX_OUTPUT_BYTES=10485760 # cap captured stdout+stderr (10 MiB) +# CODEGEN_COMMAND= # generic only: binary path +# CODEGEN_ARGS= # generic only: comma-separated argv + # Teams — multi-tenant collaboration with Owner / Admin / Member roles. # When TEAMS_ENABLED is false the /api/teams and /api/invites routes are # not mounted but personal teams still get auto-created on first login so diff --git a/backend/cmd/codegen-demo/main.go b/backend/cmd/codegen-demo/main.go new file mode 100644 index 0000000..3453b5f --- /dev/null +++ b/backend/cmd/codegen-demo/main.go @@ -0,0 +1,58 @@ +// codegen-demo is a tiny CLI that proves the local Claude Code wiring works +// end-to-end. It loads CODEGEN_* env vars, builds an Agent, runs it against +// the current directory with the prompt provided on argv, and prints the +// captured output. +// +// Usage: +// +// go run ./cmd/codegen-demo "Summarise this directory in one paragraph." +// +// Prerequisites: `claude` on PATH and `claude login` already done (or +// CODEGEN_AGENT=generic with CODEGEN_COMMAND set to your CLI of choice). +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/joho/godotenv" + + "github.com/teslashibe/agent-setup/backend/internal/codegen" +) + +func main() { + _ = godotenv.Load(".env", "backend/.env") + + if len(os.Args) < 2 { + log.Fatalf("usage: %s [workDir]", filepath.Base(os.Args[0])) + } + prompt := os.Args[1] + workDir, _ := os.Getwd() + if len(os.Args) >= 3 { + workDir = os.Args[2] + } + + agent, err := codegen.LoadFromEnv() + if err != nil { + log.Fatalf("load agent: %v", err) + } + + res, err := agent.Run(context.Background(), prompt, workDir) + if err != nil { + log.Fatalf("%s failed (exit=%d): %v\n--- output (tail) ---\n%s", + agent.Name(), res.ExitCode, err, tail(res.Output, 4000)) + } + + fmt.Printf("--- %s (%s) ---\n%s\n", + agent.Name(), res.Duration.Round(1e6), res.Output) +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return s[len(s)-n:] +} diff --git a/backend/go.mod b/backend/go.mod index 17a2138..b377154 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,7 +8,22 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.27.0 + github.com/teslashibe/codegen-go v0.1.1 + github.com/teslashibe/elevenlabs-go v1.1.2 + github.com/teslashibe/facebook-go v0.3.2 + github.com/teslashibe/hn-go v0.3.2 + github.com/teslashibe/instagram-go v1.2.2 + github.com/teslashibe/linkedin-go v1.2.3 github.com/teslashibe/magiclink-auth-go v0.2.0 + github.com/teslashibe/mcptool v0.1.1 + github.com/teslashibe/nextdoor-go v1.2.1 + github.com/teslashibe/producthunt-go v0.3.3 + github.com/teslashibe/reddit-go v1.2.3 + github.com/teslashibe/redditviral-go v0.2.0 + github.com/teslashibe/threads-go v1.2.0 + github.com/teslashibe/tiktok-go v1.2.1 + github.com/teslashibe/x-go v1.6.1 + github.com/teslashibe/x-viral-go v0.2.0 github.com/valyala/fasthttp v1.70.0 ) @@ -23,6 +38,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -30,22 +46,8 @@ require ( github.com/mfridman/interpolate v0.0.2 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect - github.com/teslashibe/codegen-go v0.1.1 // indirect - github.com/teslashibe/elevenlabs-go v1.1.2 // indirect - github.com/teslashibe/facebook-go v0.3.2 // indirect - github.com/teslashibe/hn-go v0.3.2 // indirect - github.com/teslashibe/instagram-go v1.2.2 // indirect - github.com/teslashibe/linkedin-go v1.2.3 // indirect - github.com/teslashibe/mcptool v0.1.1 // indirect - github.com/teslashibe/nextdoor-go v1.2.1 // indirect - github.com/teslashibe/producthunt-go v0.3.3 // indirect - github.com/teslashibe/reddit-go v1.2.3 // indirect - github.com/teslashibe/redditviral-go v0.2.0 // indirect - github.com/teslashibe/threads-go v1.2.0 // indirect - github.com/teslashibe/tiktok-go v1.2.1 // indirect - github.com/teslashibe/x-go v1.6.1 // indirect - github.com/teslashibe/x-viral-go v0.2.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 807d7f2..538c74f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,7 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -34,6 +35,10 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -57,6 +62,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -64,8 +71,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/teslashibe/codegen-go v0.0.0-20260422194151-eab5fd43375e h1:yqNWDftP0+p3zqeRQNwrzIAnx0VnNZkexE8rHwSoRUI= -github.com/teslashibe/codegen-go v0.0.0-20260422194151-eab5fd43375e/go.mod h1:O3u8V2cEXlR8T13EOZAwWVjSGhqkjVm+kZU6cUrwFk8= github.com/teslashibe/codegen-go v0.1.1 h1:kubDBOg5QaZurVGcm+WmKgRC6IuJhKTTDpKV5nmimf0= github.com/teslashibe/codegen-go v0.1.1/go.mod h1:MOw2rOI6r8Tp2oZMl6vpORD2zrQ7huPG5pBZk44M2K4= github.com/teslashibe/elevenlabs-go v1.1.2 h1:Ni4Mhw1ekjtmBcCik7JAtGwHya2oadsyBW1dpBWX0c0= @@ -90,8 +95,6 @@ github.com/teslashibe/reddit-go v1.2.3 h1:DRpXhRbNblPl6kqTD8LuN+UO+I46eXcvp6mXHX github.com/teslashibe/reddit-go v1.2.3/go.mod h1:Mxq5g2H6mkZkLKtgimo5gsKpl0pTI600YvCzumUR+3I= github.com/teslashibe/redditviral-go v0.2.0 h1:Y2rdFU1RioPR0AgPolQjItGytyeT2AHs1a1/IBIBjvU= github.com/teslashibe/redditviral-go v0.2.0/go.mod h1:+W9P/ooqtFY34vRwjH7fZGBW0ehxuJA9GtvbcXGo1Aw= -github.com/teslashibe/threads-go v1.1.2 h1:l5+NvPDfRsqEqBJ5mUURmFLe2uHbZvCbJ3QxkTKTCdw= -github.com/teslashibe/threads-go v1.1.2/go.mod h1:JsSax+WV15LA1WiC784loAJfdQLjdQUFFh0YUjumOIQ= github.com/teslashibe/threads-go v1.2.0 h1:ZVdjt3n5BpJYiCMKjYyxkck/Q3Hhlp/gqhk8DuD6cKM= github.com/teslashibe/threads-go v1.2.0/go.mod h1:Et38f/uB6+Fl9mPCmtg2mCrTj9dQ1+S+W/2jS+N1fsE= github.com/teslashibe/tiktok-go v1.2.1 h1:5SZJkU62Xf4KT3HKj2HwiMu5zKHAB8iRJwT4HkFIA50= @@ -135,6 +138,8 @@ golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/codegen/codegen.go b/backend/internal/codegen/codegen.go new file mode 100644 index 0000000..b41027b --- /dev/null +++ b/backend/internal/codegen/codegen.go @@ -0,0 +1,113 @@ +// Package codegen wires github.com/teslashibe/codegen-go into the agent-setup +// template as an OPTIONAL local-execution path that runs the Claude Code CLI +// (or any other "prompt-on-stdin, edit-files-in-cwd" agent) inside this +// process's working directory. +// +// This is complementary to — not a replacement for — the Anthropic Managed +// Agents path used by the `internal/agent` package. Use this shim when you +// need the agent to: +// +// - Run on this machine (filesystem access, on-prem, off-platform tools). +// - Drive a different CLI (Codex, Aider, OpenHands, Cline, your own). +// - Operate inside a long-running process under your direct control. +// +// Configuration is read from CODEGEN_* environment variables; see +// LoadFromEnv. Defaults match codegen-go upstream. +package codegen + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + cgg "github.com/teslashibe/codegen-go" +) + +// Re-exports so callers can keep using `codegen.X` without juggling two +// import paths. +type ( + Agent = cgg.Agent + Config = cgg.Config + Result = cgg.Result +) + +// New constructs an Agent directly from cfg. Thin alias around cgg.NewAgent. +func New(cfg Config) (Agent, error) { return cgg.NewAgent(cfg) } + +// LoadFromEnv builds an Agent from CODEGEN_* environment variables: +// +// CODEGEN_AGENT "claude-code" (default) | "generic" +// CODEGEN_MODEL optional --model override forwarded to claude +// CODEGEN_TIMEOUT Go duration (default 30m) +// CODEGEN_MAX_OUTPUT_BYTES cap on captured stdout+stderr (default 10 MiB) +// CODEGEN_COMMAND binary for the generic CLI agent +// CODEGEN_ARGS comma-separated argv prepended to the generic CLI +// +// Returns an error if CODEGEN_AGENT is unrecognised or if generic mode is +// selected without CODEGEN_COMMAND. +func LoadFromEnv() (Agent, error) { + cfg := Config{ + Type: getEnv("CODEGEN_AGENT", "claude-code"), + Model: strings.TrimSpace(os.Getenv("CODEGEN_MODEL")), + Timeout: getDurationEnv("CODEGEN_TIMEOUT", cgg.DefaultTimeout), + MaxOutputBytes: getIntEnv("CODEGEN_MAX_OUTPUT_BYTES", cgg.DefaultMaxOutputBytes), + Command: strings.TrimSpace(os.Getenv("CODEGEN_COMMAND")), + Args: splitCSV(os.Getenv("CODEGEN_ARGS")), + } + if cfg.Type == "generic" && cfg.Command == "" { + return nil, fmt.Errorf("codegen: CODEGEN_AGENT=generic requires CODEGEN_COMMAND") + } + return cgg.NewAgent(cfg) +} + +func getEnv(key, fallback string) string { + v, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(v) == "" { + return fallback + } + return strings.TrimSpace(v) +} + +func getIntEnv(key string, fallback int) int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} + +func getDurationEnv(key string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + d, err := time.ParseDuration(raw) + if err != nil { + return fallback + } + return d +} + +func splitCSV(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if v := strings.TrimSpace(p); v != "" { + out = append(out, v) + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/backend/internal/codegen/codegen_test.go b/backend/internal/codegen/codegen_test.go new file mode 100644 index 0000000..a9d086f --- /dev/null +++ b/backend/internal/codegen/codegen_test.go @@ -0,0 +1,72 @@ +package codegen + +import ( + "strings" + "testing" +) + +func TestLoadFromEnv_Defaults(t *testing.T) { + t.Setenv("CODEGEN_AGENT", "") + t.Setenv("CODEGEN_COMMAND", "") + t.Setenv("CODEGEN_ARGS", "") + + a, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv: %v", err) + } + if a.Name() != "claude-code" { + t.Fatalf("Name() = %q, want claude-code", a.Name()) + } +} + +func TestLoadFromEnv_Generic(t *testing.T) { + t.Setenv("CODEGEN_AGENT", "generic") + t.Setenv("CODEGEN_COMMAND", "echo") + t.Setenv("CODEGEN_ARGS", "hi, there") + + a, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv: %v", err) + } + if a.Name() != "generic" { + t.Fatalf("Name() = %q, want generic", a.Name()) + } +} + +func TestLoadFromEnv_GenericMissingCommand(t *testing.T) { + t.Setenv("CODEGEN_AGENT", "generic") + t.Setenv("CODEGEN_COMMAND", "") + + _, err := LoadFromEnv() + if err == nil { + t.Fatal("expected error when generic agent has no command") + } + if !strings.Contains(err.Error(), "CODEGEN_COMMAND") { + t.Fatalf("error %q should mention CODEGEN_COMMAND", err.Error()) + } +} + +func TestSplitCSV(t *testing.T) { + tests := []struct { + in string + want []string + }{ + {"", nil}, + {" ", nil}, + {"a", []string{"a"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {"a, b , c ", []string{"a", "b", "c"}}, + {"a,,b", []string{"a", "b"}}, + } + for _, tc := range tests { + got := splitCSV(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("splitCSV(%q)[%d] = %q, want %q", tc.in, i, got[i], tc.want[i]) + } + } + } +}