From 2604eb9a38f81dc6a62b12707c15274d2eeed9b3 Mon Sep 17 00:00:00 2001 From: lonestarx1 Date: Mon, 16 Feb 2026 21:10:32 +0900 Subject: [PATCH 1/3] Implement Phase 9: CLI and developer experience Add a proper CLI with subcommand dispatch (version, init, list, run, trace, cost) replacing the single-line stub. YAML-based project configuration via gopkg.in/yaml.v3 with env var substitution (${VAR}, ${VAR:-default}). Project scaffolding templates for single agent, team, and pipeline patterns. Run record persistence under .gogrid/runs/ for trace and cost inspection. Version injection via -ldflags at build time. New packages: internal/config, internal/runrecord, internal/cli, internal/cli/templates. Only new external dependency: yaml.v3. --- Makefile | 4 +- cmd/gogrid/main.go | 9 +- go.mod | 1 + go.sum | 1 + internal/cli/cli.go | 96 ++++++++++++ internal/cli/cli_test.go | 63 ++++++++ internal/cli/cost.go | 173 ++++++++++++++++++++++ internal/cli/cost_test.go | 146 +++++++++++++++++++ internal/cli/init.go | 106 ++++++++++++++ internal/cli/init_test.go | 129 ++++++++++++++++ internal/cli/list.go | 43 ++++++ internal/cli/list_test.go | 65 +++++++++ internal/cli/provider.go | 43 ++++++ internal/cli/run.go | 127 ++++++++++++++++ internal/cli/run_test.go | 183 +++++++++++++++++++++++ internal/cli/templates/pipeline.go | 122 ++++++++++++++++ internal/cli/templates/single.go | 106 ++++++++++++++ internal/cli/templates/team.go | 128 ++++++++++++++++ internal/cli/templates/templates.go | 39 +++++ internal/cli/trace.go | 163 +++++++++++++++++++++ internal/cli/trace_test.go | 175 ++++++++++++++++++++++ internal/cli/version.go | 12 ++ internal/cli/version_test.go | 46 ++++++ internal/config/config.go | 123 ++++++++++++++++ internal/config/config_test.go | 218 ++++++++++++++++++++++++++++ internal/config/doc.go | 2 - internal/config/envsubst.go | 52 +++++++ internal/config/envsubst_test.go | 87 +++++++++++ internal/runrecord/record.go | 103 +++++++++++++ internal/runrecord/record_test.go | 171 ++++++++++++++++++++++ 30 files changed, 2731 insertions(+), 5 deletions(-) create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/cost.go create mode 100644 internal/cli/cost_test.go create mode 100644 internal/cli/init.go create mode 100644 internal/cli/init_test.go create mode 100644 internal/cli/list.go create mode 100644 internal/cli/list_test.go create mode 100644 internal/cli/provider.go create mode 100644 internal/cli/run.go create mode 100644 internal/cli/run_test.go create mode 100644 internal/cli/templates/pipeline.go create mode 100644 internal/cli/templates/single.go create mode 100644 internal/cli/templates/team.go create mode 100644 internal/cli/templates/templates.go create mode 100644 internal/cli/trace.go create mode 100644 internal/cli/trace_test.go create mode 100644 internal/cli/version.go create mode 100644 internal/cli/version_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go delete mode 100644 internal/config/doc.go create mode 100644 internal/config/envsubst.go create mode 100644 internal/config/envsubst_test.go create mode 100644 internal/runrecord/record.go create mode 100644 internal/runrecord/record_test.go diff --git a/Makefile b/Makefile index 495f6a2..480ca98 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ .PHONY: build test lint fmt vet clean ci website +VERSION ?= dev + # Build the gogrid CLI binary build: - go build -o bin/gogrid ./cmd/gogrid + go build -ldflags "-X github.com/lonestarx1/gogrid/internal/cli.Version=$(VERSION)" -o bin/gogrid ./cmd/gogrid # Run all tests test: diff --git a/cmd/gogrid/main.go b/cmd/gogrid/main.go index 0df3768..7902582 100644 --- a/cmd/gogrid/main.go +++ b/cmd/gogrid/main.go @@ -1,8 +1,13 @@ // GoGrid (G2) CLI entry point. package main -import "fmt" +import ( + "os" + + "github.com/lonestarx1/gogrid/internal/cli" +) func main() { - fmt.Println("gogrid — Kubernetes for AI agents") + app := cli.New(os.Stdout, os.Stderr) + os.Exit(app.Run(os.Args[1:])) } diff --git a/go.mod b/go.mod index 15f3491..a045b8e 100644 --- a/go.mod +++ b/go.mod @@ -26,4 +26,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/grpc v1.66.2 // indirect google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index caef445..fc6c2e9 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,7 @@ google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6h google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..d764bf7 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,96 @@ +// Package cli implements the GoGrid command-line interface. +package cli + +import ( + "context" + "fmt" + "io" + + "github.com/lonestarx1/gogrid/pkg/llm" +) + +// ProviderFactory creates an LLM provider by name. +// The default implementation resolves API keys from environment variables. +type ProviderFactory func(ctx context.Context, name string) (llm.Provider, error) + +// App is the GoGrid CLI application. +type App struct { + stdout io.Writer + stderr io.Writer + providerFactory ProviderFactory +} + +// New creates a CLI application that writes to the given writers. +func New(stdout, stderr io.Writer) *App { + return &App{ + stdout: stdout, + stderr: stderr, + providerFactory: defaultProviderFactory, + } +} + +// SetProviderFactory overrides the default provider factory (for testing). +func (a *App) SetProviderFactory(f ProviderFactory) { + a.providerFactory = f +} + +// Run dispatches to the appropriate subcommand and returns an exit code. +func (a *App) Run(args []string) int { + if len(args) == 0 { + a.printUsage() + return 0 + } + + cmd := args[0] + cmdArgs := args[1:] + + switch cmd { + case "version": + return a.runVersion() + case "init": + return a.runInit(cmdArgs) + case "list": + return a.runList(cmdArgs) + case "run": + return a.runRun(cmdArgs) + case "trace": + return a.runTrace(cmdArgs) + case "cost": + return a.runCost(cmdArgs) + case "help", "-h", "--help": + a.printUsage() + return 0 + default: + a.errf("unknown command: %s\n\n", cmd) + a.printUsage() + return 1 + } +} + +func (a *App) printUsage() { + a.outf(`gogrid — Kubernetes for AI agents + +Usage: gogrid [flags] + +Commands: + init Scaffold a new GoGrid project + list List agents defined in gogrid.yaml + run Execute an agent + trace Inspect execution traces + cost View cost breakdown + version Print version information + help Show this help message + +Run 'gogrid -h' for command-specific help. +`) +} + +// outf writes to stdout, ignoring write errors (terminal I/O). +func (a *App) outf(format string, args ...any) { + _, _ = fmt.Fprintf(a.stdout, format, args...) +} + +// errf writes to stderr, ignoring write errors (terminal I/O). +func (a *App) errf(format string, args ...any) { + _, _ = fmt.Fprintf(a.stderr, format, args...) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..c350646 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestRun_NoArgs(t *testing.T) { + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.Run(nil) + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "Usage:") { + t.Error("expected usage message in stdout") + } +} + +func TestRun_Help(t *testing.T) { + for _, arg := range []string{"help", "-h", "--help"} { + t.Run(arg, func(t *testing.T) { + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.Run([]string{arg}) + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "Commands:") { + t.Error("expected commands list in output") + } + }) + } +} + +func TestRun_UnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.Run([]string{"bogus"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "unknown command") { + t.Error("expected unknown command error in stderr") + } +} + +func TestRun_Version(t *testing.T) { + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.Run([]string{"version"}) + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "gogrid") { + t.Error("expected 'gogrid' in version output") + } +} diff --git a/internal/cli/cost.go b/internal/cli/cost.go new file mode 100644 index 0000000..b17cf05 --- /dev/null +++ b/internal/cli/cost.go @@ -0,0 +1,173 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/lonestarx1/gogrid/internal/runrecord" + "github.com/lonestarx1/gogrid/pkg/llm" +) + +func (a *App) runCost(args []string) int { + fs := flag.NewFlagSet("cost", flag.ContinueOnError) + fs.SetOutput(a.stderr) + jsonOutput := fs.Bool("json", false, "output as JSON") + + if err := fs.Parse(args); err != nil { + return 1 + } + + // No run-id: list all runs with cost. + if fs.NArg() == 0 { + return a.listRunCosts(*jsonOutput) + } + + runID := fs.Arg(0) + rec, err := runrecord.Load(".", runID) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + + if *jsonOutput { + return a.costJSON(rec) + } + + a.renderCostTable(rec) + return 0 +} + +func (a *App) listRunCosts(jsonOut bool) int { + ids, err := runrecord.List(".") + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + if len(ids) == 0 { + a.outf("No runs found. Run 'gogrid run ' first.\n") + return 0 + } + + type runSummary struct { + RunID string `json:"run_id"` + Agent string `json:"agent"` + Model string `json:"model"` + Cost float64 `json:"cost"` + } + + var summaries []runSummary + for _, id := range ids { + rec, err := runrecord.Load(".", id) + if err != nil { + continue + } + summaries = append(summaries, runSummary{ + RunID: rec.RunID, + Agent: rec.Agent, + Model: rec.Model, + Cost: rec.Cost, + }) + } + + if jsonOut { + data, _ := json.MarshalIndent(summaries, "", " ") + a.outf("%s\n", data) + return 0 + } + + w := tabwriter.NewWriter(a.stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "RUN ID\tAGENT\tMODEL\tCOST") + for _, s := range summaries { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t$%.6f\n", s.RunID, s.Agent, s.Model, s.Cost) + } + _ = w.Flush() + return 0 +} + +type modelCost struct { + Model string `json:"model"` + Calls int `json:"calls"` + Usage llm.Usage `json:"usage"` + Cost float64 `json:"cost"` +} + +func (a *App) costJSON(rec *runrecord.Record) int { + models := aggregateByModel(rec) + data, err := json.MarshalIndent(models, "", " ") + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + a.outf("%s\n", data) + return 0 +} + +func (a *App) renderCostTable(rec *runrecord.Record) { + a.outf("Run: %s\n\n", rec.RunID) + + models := aggregateByModel(rec) + + w := tabwriter.NewWriter(a.stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "MODEL\tCALLS\tPROMPT\tCOMPLETION\tCOST") + + var totalCalls int + var totalPrompt, totalCompletion int + var totalCost float64 + for _, m := range models { + _, _ = fmt.Fprintf(w, "%s\t%d\t%d\t%d\t$%.6f\n", + m.Model, m.Calls, m.Usage.PromptTokens, m.Usage.CompletionTokens, m.Cost) + totalCalls += m.Calls + totalPrompt += m.Usage.PromptTokens + totalCompletion += m.Usage.CompletionTokens + totalCost += m.Cost + } + + _, _ = fmt.Fprintln(w, strings.Repeat("\u2500", 60)+"\t\t\t\t") + _, _ = fmt.Fprintf(w, "TOTAL\t%d\t%d\t%d\t$%.6f\n", + totalCalls, totalPrompt, totalCompletion, totalCost) + _ = w.Flush() +} + +func aggregateByModel(rec *runrecord.Record) []modelCost { + byModel := make(map[string]*modelCost) + + if len(rec.CostRecords) > 0 { + for _, cr := range rec.CostRecords { + mc, ok := byModel[cr.Model] + if !ok { + mc = &modelCost{Model: cr.Model} + byModel[cr.Model] = mc + } + mc.Calls++ + mc.Usage.PromptTokens += cr.Usage.PromptTokens + mc.Usage.CompletionTokens += cr.Usage.CompletionTokens + mc.Usage.TotalTokens += cr.Usage.TotalTokens + mc.Cost += cr.Cost + } + } else { + // Fallback: use the record's aggregate usage. + byModel[rec.Model] = &modelCost{ + Model: rec.Model, + Calls: rec.Turns, + Usage: rec.Usage, + Cost: rec.Cost, + } + } + + // Sort by model name. + names := make([]string, 0, len(byModel)) + for name := range byModel { + names = append(names, name) + } + sort.Strings(names) + + result := make([]modelCost, 0, len(names)) + for _, name := range names { + result = append(result, *byModel[name]) + } + return result +} diff --git a/internal/cli/cost_test.go b/internal/cli/cost_test.go new file mode 100644 index 0000000..c7b2501 --- /dev/null +++ b/internal/cli/cost_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "bytes" + "os" + "strings" + "testing" + "time" + + "github.com/lonestarx1/gogrid/internal/runrecord" + "github.com/lonestarx1/gogrid/pkg/cost" + "github.com/lonestarx1/gogrid/pkg/llm" +) + +func saveCostTestRecord(t *testing.T, dir string) { + t.Helper() + rec := &runrecord.Record{ + RunID: "cost-run-001", + Agent: "researcher", + Model: "claude-sonnet-4-5-20250929", + Provider: "anthropic", + Turns: 2, + Usage: llm.Usage{PromptTokens: 430, CompletionTokens: 134, TotalTokens: 564}, + Cost: 0.003280, + CostRecords: []cost.Record{ + { + Model: "claude-sonnet-4-5-20250929", + Usage: llm.Usage{PromptTokens: 200, CompletionTokens: 89, TotalTokens: 289}, + Cost: 0.001935, + Time: time.Now(), + }, + { + Model: "claude-sonnet-4-5-20250929", + Usage: llm.Usage{PromptTokens: 230, CompletionTokens: 45, TotalTokens: 275}, + Cost: 0.001345, + Time: time.Now(), + }, + }, + StartTime: time.Now(), + Duration: 3 * time.Second, + } + if err := runrecord.Save(dir, rec); err != nil { + t.Fatal(err) + } +} + +func TestRunCost_Table(t *testing.T) { + dir := t.TempDir() + saveCostTestRecord(t, dir) + + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runCost([]string{"cost-run-001"}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "cost-run-001") { + t.Error("expected run ID") + } + if !strings.Contains(out, "claude-sonnet-4-5-20250929") { + t.Error("expected model name") + } + if !strings.Contains(out, "TOTAL") { + t.Error("expected TOTAL row") + } + if !strings.Contains(out, "MODEL") { + t.Error("expected table header") + } +} + +func TestRunCost_JSONOutput(t *testing.T) { + dir := t.TempDir() + saveCostTestRecord(t, dir) + + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runCost([]string{"-json", "cost-run-001"}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"model"`) { + t.Error("expected JSON with model field") + } + if !strings.Contains(out, `"cost"`) { + t.Error("expected JSON with cost field") + } +} + +func TestRunCost_MissingRunID(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runCost([]string{"nonexistent"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } +} + +func TestRunCost_NoArgs_ListAll(t *testing.T) { + dir := t.TempDir() + saveCostTestRecord(t, dir) + + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runCost(nil) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "cost-run-001") { + t.Error("expected run ID in list") + } +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..ba3e029 --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,106 @@ +package cli + +import ( + "flag" + "os" + "path/filepath" + "text/template" + + "github.com/lonestarx1/gogrid/internal/cli/templates" +) + +func (a *App) runInit(args []string) int { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + fs.SetOutput(a.stderr) + tmplName := fs.String("template", "single", "project template (single, team, pipeline)") + projName := fs.String("name", "", "project name (defaults to directory name)") + + if err := fs.Parse(args); err != nil { + return 1 + } + + dir := "." + if fs.NArg() > 0 { + dir = fs.Arg(0) + } + + // Validate template. + tmpl := templates.Get(*tmplName) + if tmpl == nil { + a.errf("Error: unknown template %q (valid: single, team, pipeline)\n", *tmplName) + return 1 + } + + // Resolve project name. + name := *projName + if name == "" { + absDir, err := filepath.Abs(dir) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + name = filepath.Base(absDir) + } + + // Check target directory. + if dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + a.errf("Error: %v\n", err) + return 1 + } + } + entries, err := os.ReadDir(dir) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + // Allow if only hidden files exist. + for _, e := range entries { + if e.Name()[0] != '.' { + a.errf("Error: directory %q is not empty\n", dir) + return 1 + } + } + + // Render template files. + data := templates.Data{ + Name: name, + Module: "github.com/example/" + name, + } + + for _, f := range tmpl.Files { + path := filepath.Join(dir, f.Path) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + a.errf("Error: %v\n", err) + return 1 + } + + t, err := template.New(f.Path).Parse(f.Content) + if err != nil { + a.errf("Error: parsing template %s: %v\n", f.Path, err) + return 1 + } + + out, err := os.Create(path) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + + if err := t.Execute(out, data); err != nil { + _ = out.Close() + a.errf("Error: rendering %s: %v\n", f.Path, err) + return 1 + } + _ = out.Close() + } + + a.outf("Created GoGrid project %q with %s template in %s\n", name, *tmplName, dir) + a.outf("\nNext steps:\n") + a.outf(" cd %s\n", dir) + a.outf(" go mod tidy\n") + a.outf(" export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY / GEMINI_API_KEY\n") + a.outf(" gogrid run -input \"hello\"\n") + + return 0 +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..395ad22 --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,129 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunInit_SingleTemplate(t *testing.T) { + dir := filepath.Join(t.TempDir(), "myproject") + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runInit([]string{"-template", "single", "-name", "myproject", dir}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + // Check files exist. + for _, f := range []string{"gogrid.yaml", "main.go", "Makefile", "README.md"} { + path := filepath.Join(dir, f) + if _, err := os.Stat(path); err != nil { + t.Errorf("expected %s to exist: %v", f, err) + } + } + + // Verify config is valid YAML with version "1". + data, _ := os.ReadFile(filepath.Join(dir, "gogrid.yaml")) + if !strings.Contains(string(data), `version: "1"`) { + t.Error("expected version 1 in generated config") + } + + if !strings.Contains(stdout.String(), "Created GoGrid project") { + t.Error("expected success message") + } +} + +func TestRunInit_TeamTemplate(t *testing.T) { + dir := filepath.Join(t.TempDir(), "teamproject") + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runInit([]string{"-template", "team", dir}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + data, _ := os.ReadFile(filepath.Join(dir, "gogrid.yaml")) + if !strings.Contains(string(data), "researcher") { + t.Error("expected researcher agent in team config") + } + if !strings.Contains(string(data), "reviewer") { + t.Error("expected reviewer agent in team config") + } +} + +func TestRunInit_PipelineTemplate(t *testing.T) { + dir := filepath.Join(t.TempDir(), "pipeproject") + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runInit([]string{"-template", "pipeline", dir}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + data, _ := os.ReadFile(filepath.Join(dir, "gogrid.yaml")) + if !strings.Contains(string(data), "drafter") { + t.Error("expected drafter agent in pipeline config") + } + if !strings.Contains(string(data), "editor") { + t.Error("expected editor agent in pipeline config") + } +} + +func TestRunInit_NonEmptyDir(t *testing.T) { + dir := t.TempDir() + // Create a visible file. + if err := os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runInit([]string{dir}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "not empty") { + t.Errorf("expected non-empty error, got: %s", stderr.String()) + } +} + +func TestRunInit_InvalidTemplate(t *testing.T) { + dir := filepath.Join(t.TempDir(), "proj") + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runInit([]string{"-template", "invalid", dir}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "unknown template") { + t.Errorf("expected unknown template error, got: %s", stderr.String()) + } +} + +func TestRunInit_DefaultsToDirectoryName(t *testing.T) { + dir := filepath.Join(t.TempDir(), "my-cool-project") + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runInit([]string{dir}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + if !strings.Contains(stdout.String(), "my-cool-project") { + t.Error("expected directory name as project name") + } +} diff --git a/internal/cli/list.go b/internal/cli/list.go new file mode 100644 index 0000000..efa86d9 --- /dev/null +++ b/internal/cli/list.go @@ -0,0 +1,43 @@ +package cli + +import ( + "flag" + "fmt" + "sort" + "text/tabwriter" + + "github.com/lonestarx1/gogrid/internal/config" +) + +func (a *App) runList(args []string) int { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + fs.SetOutput(a.stderr) + configPath := fs.String("config", "gogrid.yaml", "path to gogrid.yaml") + + if err := fs.Parse(args); err != nil { + return 1 + } + + cfg, err := config.Load(*configPath) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + + // Sort agent names for stable output. + names := make([]string, 0, len(cfg.Agents)) + for name := range cfg.Agents { + names = append(names, name) + } + sort.Strings(names) + + w := tabwriter.NewWriter(a.stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "NAME\tPROVIDER\tMODEL") + for _, name := range names { + agent := cfg.Agents[name] + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", name, agent.Provider, agent.Model) + } + _ = w.Flush() + + return 0 +} diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go new file mode 100644 index 0000000..15dca68 --- /dev/null +++ b/internal/cli/list_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunList_ValidConfig(t *testing.T) { + dir := t.TempDir() + yaml := `version: "1" +agents: + researcher: + model: claude-sonnet-4-5-20250929 + provider: anthropic + instructions: Research things. + summarizer: + model: gpt-4o-mini + provider: openai + instructions: Summarize things. +` + if err := os.WriteFile(filepath.Join(dir, "gogrid.yaml"), []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runList([]string{"-config", filepath.Join(dir, "gogrid.yaml")}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "NAME") { + t.Error("expected table header") + } + if !strings.Contains(out, "researcher") { + t.Error("expected researcher in output") + } + if !strings.Contains(out, "summarizer") { + t.Error("expected summarizer in output") + } + if !strings.Contains(out, "anthropic") { + t.Error("expected anthropic in output") + } + if !strings.Contains(out, "openai") { + t.Error("expected openai in output") + } +} + +func TestRunList_MissingConfig(t *testing.T) { + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runList([]string{"-config", "/nonexistent/gogrid.yaml"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Error") { + t.Error("expected error in stderr") + } +} diff --git a/internal/cli/provider.go b/internal/cli/provider.go new file mode 100644 index 0000000..a0a9dd8 --- /dev/null +++ b/internal/cli/provider.go @@ -0,0 +1,43 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/lonestarx1/gogrid/pkg/llm" + "github.com/lonestarx1/gogrid/pkg/llm/anthropic" + "github.com/lonestarx1/gogrid/pkg/llm/gemini" + "github.com/lonestarx1/gogrid/pkg/llm/openai" +) + +// envKeys maps provider names to their API key environment variables. +var envKeys = map[string]string{ + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "gemini": "GEMINI_API_KEY", +} + +// defaultProviderFactory creates providers using API keys from environment variables. +func defaultProviderFactory(ctx context.Context, name string) (llm.Provider, error) { + envKey, ok := envKeys[name] + if !ok { + return nil, fmt.Errorf("unknown provider %q", name) + } + + apiKey := os.Getenv(envKey) + if apiKey == "" { + return nil, fmt.Errorf("%s is not set (required for provider %q)", envKey, name) + } + + switch name { + case "openai": + return openai.New(apiKey), nil + case "anthropic": + return anthropic.New(apiKey), nil + case "gemini": + return gemini.New(ctx, apiKey) + default: + return nil, fmt.Errorf("unknown provider %q", name) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..0ac4430 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,127 @@ +package cli + +import ( + "context" + "flag" + "time" + + "github.com/lonestarx1/gogrid/internal/config" + "github.com/lonestarx1/gogrid/internal/runrecord" + "github.com/lonestarx1/gogrid/pkg/agent" + "github.com/lonestarx1/gogrid/pkg/trace" +) + +func (a *App) runRun(args []string) int { + fs := flag.NewFlagSet("run", flag.ContinueOnError) + fs.SetOutput(a.stderr) + configPath := fs.String("config", "gogrid.yaml", "path to gogrid.yaml") + input := fs.String("input", "", "input text (reads stdin if empty)") + timeout := fs.Duration("timeout", 0, "override timeout (e.g. 30s, 5m)") + + if err := fs.Parse(args); err != nil { + return 1 + } + + if fs.NArg() == 0 { + a.errf("Usage: gogrid run [flags]\n") + return 1 + } + agentName := fs.Arg(0) + + cfg, err := config.Load(*configPath) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + + agentCfg, ok := cfg.Agents[agentName] + if !ok { + a.errf("Error: unknown agent %q\n", agentName) + a.errf("Available agents:\n") + for name := range cfg.Agents { + a.errf(" - %s\n", name) + } + return 1 + } + + // Read input from flag or stdin. + inputText := *input + if inputText == "" { + a.errf("Error: no input provided (use -input flag or pipe via stdin)\n") + return 1 + } + + // Resolve provider. + ctx := context.Background() + provider, err := a.providerFactory(ctx, agentCfg.Provider) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + + // Build agent options. + agentTimeout := agentCfg.Config.Timeout.Duration + if *timeout > 0 { + agentTimeout = *timeout + } + + tracer := trace.NewInMemory() + + ag := agent.New(agentName, + agent.WithModel(agentCfg.Model), + agent.WithProvider(provider), + agent.WithInstructions(agentCfg.Instructions), + agent.WithTracer(tracer), + agent.WithConfig(agent.Config{ + MaxTurns: agentCfg.Config.MaxTurns, + MaxTokens: agentCfg.Config.MaxTokens, + Temperature: agentCfg.Config.Temperature, + Timeout: agentTimeout, + CostBudget: agentCfg.Config.CostBudget, + }), + ) + + // Execute. + start := time.Now() + result, err := ag.Run(ctx, inputText) + duration := time.Since(start) + + // Build run record. + rec := &runrecord.Record{ + Agent: agentName, + Model: agentCfg.Model, + Provider: agentCfg.Provider, + Input: inputText, + StartTime: start, + Duration: duration, + } + + if err != nil { + rec.Error = err.Error() + rec.RunID = "error-" + time.Now().Format("20060102-150405") + rec.Spans = tracer.Spans() + // Still save the record for debugging. + _ = runrecord.Save(".", rec) + a.errf("Error: %v\n", err) + return 1 + } + + rec.RunID = result.RunID + rec.Output = result.Message.Content + rec.Turns = result.Turns + rec.Usage = result.Usage + rec.Cost = result.Cost + rec.Spans = tracer.Spans() + + // Print response. + a.outf("%s\n", result.Message.Content) + + // Save run record. + if err := runrecord.Save(".", rec); err != nil { + a.errf("Warning: failed to save run record: %v\n", err) + } else { + a.errf("\nRun ID: %s\n", rec.RunID) + } + + return 0 +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..299190a --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,183 @@ +package cli + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lonestarx1/gogrid/pkg/llm" +) + +// mockProvider returns a canned response for testing. +type mockProvider struct { + response string +} + +func (m *mockProvider) Complete(_ context.Context, params llm.Params) (*llm.Response, error) { + return &llm.Response{ + Message: llm.Message{ + Role: llm.RoleAssistant, + Content: m.response, + }, + Usage: llm.Usage{ + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + }, + Model: params.Model, + }, nil +} + +func newMockFactory(resp string) ProviderFactory { + return func(_ context.Context, _ string) (llm.Provider, error) { + return &mockProvider{response: resp}, nil + } +} + +func newFailingFactory(msg string) ProviderFactory { + return func(_ context.Context, _ string) (llm.Provider, error) { + return nil, fmt.Errorf("%s", msg) + } +} + +func writeTestConfig(t *testing.T, dir string) string { + t.Helper() + yaml := `version: "1" +agents: + helper: + model: test-model + provider: openai + instructions: You are helpful. + config: + max_turns: 1 +` + path := filepath.Join(dir, "gogrid.yaml") + if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestRunRun_Success(t *testing.T) { + dir := t.TempDir() + configPath := writeTestConfig(t, dir) + + // Change to temp dir so run record is saved there. + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + app.SetProviderFactory(newMockFactory("Hello from mock!")) + + code := app.runRun([]string{"-config", configPath, "-input", "test", "helper"}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + if !strings.Contains(stdout.String(), "Hello from mock!") { + t.Errorf("expected mock response in stdout, got: %s", stdout.String()) + } + + // Verify run record was saved. + if !strings.Contains(stderr.String(), "Run ID:") { + t.Errorf("expected run ID in stderr, got: %s", stderr.String()) + } +} + +func TestRunRun_UnknownAgent(t *testing.T) { + dir := t.TempDir() + configPath := writeTestConfig(t, dir) + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + app.SetProviderFactory(newMockFactory("")) + + code := app.runRun([]string{"-config", configPath, "-input", "test", "nonexistent"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "unknown agent") { + t.Errorf("expected unknown agent error, got: %s", stderr.String()) + } +} + +func TestRunRun_MissingAPIKey(t *testing.T) { + dir := t.TempDir() + configPath := writeTestConfig(t, dir) + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + app.SetProviderFactory(newFailingFactory("OPENAI_API_KEY is not set")) + + code := app.runRun([]string{"-config", configPath, "-input", "test", "helper"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "API_KEY") { + t.Errorf("expected API key error, got: %s", stderr.String()) + } +} + +func TestRunRun_NoAgentName(t *testing.T) { + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runRun([]string{"-input", "test"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } +} + +func TestRunRun_NoInput(t *testing.T) { + dir := t.TempDir() + configPath := writeTestConfig(t, dir) + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + app.SetProviderFactory(newMockFactory("")) + + code := app.runRun([]string{"-config", configPath, "helper"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "no input") { + t.Errorf("expected no input error, got: %s", stderr.String()) + } +} + +func TestRunRun_RecordSaved(t *testing.T) { + dir := t.TempDir() + configPath := writeTestConfig(t, dir) + + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + app.SetProviderFactory(newMockFactory("response")) + + code := app.runRun([]string{"-config", configPath, "-input", "hello", "helper"}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + // Check that a run record file was created. + entries, err := os.ReadDir(filepath.Join(dir, ".gogrid", "runs")) + if err != nil { + t.Fatalf("failed to read runs dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("expected 1 run record, got %d", len(entries)) + } +} diff --git a/internal/cli/templates/pipeline.go b/internal/cli/templates/pipeline.go new file mode 100644 index 0000000..9c78c9b --- /dev/null +++ b/internal/cli/templates/pipeline.go @@ -0,0 +1,122 @@ +package templates + +func init() { + register(&Template{ + Name: "pipeline", + Description: "Sequential pipeline with state transfer", + Files: []File{ + {Path: "gogrid.yaml", Content: pipelineConfig}, + {Path: "main.go", Content: pipelineMain}, + {Path: "Makefile", Content: pipelineMakefile}, + {Path: "README.md", Content: pipelineReadme}, + }, + }) +} + +const pipelineConfig = `version: "1" +agents: + drafter: + model: gpt-4o-mini + provider: openai + instructions: | + You are a content drafter. Write a first draft based on the topic. + Be thorough but don't worry about polish. + config: + max_turns: 5 + max_tokens: 4096 + timeout: 60s + editor: + model: gpt-4o-mini + provider: openai + instructions: | + You are an editor. Improve the draft for clarity, grammar, + and structure. Return the polished version. + config: + max_turns: 5 + max_tokens: 4096 + timeout: 60s +` + +const pipelineMain = `package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/lonestarx1/gogrid/pkg/agent" + "github.com/lonestarx1/gogrid/pkg/llm/openai" + "github.com/lonestarx1/gogrid/pkg/orchestrator/pipeline" +) + +func main() { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + log.Fatal("OPENAI_API_KEY is required") + } + + provider := openai.New(apiKey) + + drafter := agent.New("drafter", + agent.WithModel("gpt-4o-mini"), + agent.WithProvider(provider), + agent.WithInstructions("Write a first draft on the given topic."), + ) + + editor := agent.New("editor", + agent.WithModel("gpt-4o-mini"), + agent.WithProvider(provider), + agent.WithInstructions("Polish the draft for clarity and grammar."), + ) + + p := pipeline.New("content-pipeline", + pipeline.WithStages( + pipeline.Stage{Name: "draft", Agent: drafter}, + pipeline.Stage{Name: "edit", Agent: editor}, + ), + ) + + result, err := p.Run(context.Background(), "Write a blog post about Go concurrency patterns") + if err != nil { + log.Fatal(err) + } + + fmt.Println(result.Output) + fmt.Printf("\nStages: %d | Cost: $%.6f\n", len(result.Stages), result.TotalCost) +} +` + +const pipelineMakefile = `.PHONY: build run clean + +build: + go build -o bin/{{.Name}} . + +run: build + ./bin/{{.Name}} + +clean: + rm -rf bin/ +` + +const pipelineReadme = `# {{.Name}} + +A GoGrid pipeline project with sequential agent stages. + +## Setup + +` + "```" + `bash +go mod tidy +export OPENAI_API_KEY=sk-... +` + "```" + ` + +## Run + +` + "```" + `bash +# Using GoGrid CLI (runs individual agents) +gogrid run drafter -input "Go concurrency patterns" + +# Or run the full pipeline directly +go run main.go +` + "```" + ` +` diff --git a/internal/cli/templates/single.go b/internal/cli/templates/single.go new file mode 100644 index 0000000..05a785d --- /dev/null +++ b/internal/cli/templates/single.go @@ -0,0 +1,106 @@ +package templates + +func init() { + register(&Template{ + Name: "single", + Description: "Single agent with tool use", + Files: []File{ + {Path: "gogrid.yaml", Content: singleConfig}, + {Path: "main.go", Content: singleMain}, + {Path: "Makefile", Content: singleMakefile}, + {Path: "README.md", Content: singleReadme}, + }, + }) +} + +const singleConfig = `version: "1" +agents: + assistant: + model: gpt-4o-mini + provider: openai + instructions: | + You are a helpful assistant. Answer questions clearly and concisely. + config: + max_turns: 10 + max_tokens: 4096 + timeout: 60s + cost_budget: 0.50 +` + +const singleMain = `package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/lonestarx1/gogrid/pkg/agent" + "github.com/lonestarx1/gogrid/pkg/llm/openai" + "github.com/lonestarx1/gogrid/pkg/trace" +) + +func main() { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + log.Fatal("OPENAI_API_KEY is required") + } + + provider := openai.New(apiKey) + tracer := trace.NewInMemory() + + a := agent.New("assistant", + agent.WithModel("gpt-4o-mini"), + agent.WithProvider(provider), + agent.WithInstructions("You are a helpful assistant."), + agent.WithTracer(tracer), + agent.WithConfig(agent.Config{ + MaxTurns: 10, + MaxTokens: 4096, + }), + ) + + result, err := a.Run(context.Background(), "Hello! What can you help me with?") + if err != nil { + log.Fatal(err) + } + + fmt.Println(result.Message.Content) + fmt.Printf("\nTokens: %d prompt, %d completion | Cost: $%.6f\n", + result.Usage.PromptTokens, result.Usage.CompletionTokens, result.Cost) +} +` + +const singleMakefile = `.PHONY: build run clean + +build: + go build -o bin/{{.Name}} . + +run: build + ./bin/{{.Name}} + +clean: + rm -rf bin/ +` + +const singleReadme = `# {{.Name}} + +A GoGrid single-agent project. + +## Setup + +` + "```" + `bash +go mod tidy +export OPENAI_API_KEY=sk-... +` + "```" + ` + +## Run + +` + "```" + `bash +# Using GoGrid CLI +gogrid run assistant -input "Hello!" + +# Or directly +go run main.go +` + "```" + ` +` diff --git a/internal/cli/templates/team.go b/internal/cli/templates/team.go new file mode 100644 index 0000000..871f63b --- /dev/null +++ b/internal/cli/templates/team.go @@ -0,0 +1,128 @@ +package templates + +func init() { + register(&Template{ + Name: "team", + Description: "Team of agents with shared memory", + Files: []File{ + {Path: "gogrid.yaml", Content: teamConfig}, + {Path: "main.go", Content: teamMain}, + {Path: "Makefile", Content: teamMakefile}, + {Path: "README.md", Content: teamReadme}, + }, + }) +} + +const teamConfig = `version: "1" +agents: + researcher: + model: gpt-4o-mini + provider: openai + instructions: | + You are a researcher. Analyze the topic thoroughly and provide + detailed findings with sources. + config: + max_turns: 5 + max_tokens: 4096 + timeout: 60s + reviewer: + model: gpt-4o-mini + provider: openai + instructions: | + You are a critical reviewer. Evaluate the research for accuracy, + completeness, and potential biases. + config: + max_turns: 5 + max_tokens: 4096 + timeout: 60s +` + +const teamMain = `package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/lonestarx1/gogrid/pkg/agent" + "github.com/lonestarx1/gogrid/pkg/llm/openai" + "github.com/lonestarx1/gogrid/pkg/memory/shared" + "github.com/lonestarx1/gogrid/pkg/orchestrator/team" +) + +func main() { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + log.Fatal("OPENAI_API_KEY is required") + } + + provider := openai.New(apiKey) + sharedMem := shared.New() + + researcher := agent.New("researcher", + agent.WithModel("gpt-4o-mini"), + agent.WithProvider(provider), + agent.WithInstructions("You are a researcher. Provide detailed analysis."), + agent.WithMemory(sharedMem.For("researcher")), + ) + + reviewer := agent.New("reviewer", + agent.WithModel("gpt-4o-mini"), + agent.WithProvider(provider), + agent.WithInstructions("You are a reviewer. Evaluate for accuracy and gaps."), + agent.WithMemory(sharedMem.For("reviewer")), + ) + + t := team.New("research-team", + team.WithMembers( + team.Member{Agent: researcher, Role: "researcher"}, + team.Member{Agent: reviewer, Role: "reviewer"}, + ), + team.WithStrategy(team.Unanimous), + team.WithMaxRounds(3), + ) + + result, err := t.Run(context.Background(), "Analyze the impact of AI on software development") + if err != nil { + log.Fatal(err) + } + + fmt.Println(result.Decision.Content) + fmt.Printf("\nRounds: %d | Cost: $%.6f\n", result.Rounds, result.TotalCost) +} +` + +const teamMakefile = `.PHONY: build run clean + +build: + go build -o bin/{{.Name}} . + +run: build + ./bin/{{.Name}} + +clean: + rm -rf bin/ +` + +const teamReadme = `# {{.Name}} + +A GoGrid team project with multiple collaborating agents. + +## Setup + +` + "```" + `bash +go mod tidy +export OPENAI_API_KEY=sk-... +` + "```" + ` + +## Run + +` + "```" + `bash +# Using GoGrid CLI (runs individual agents) +gogrid run researcher -input "Analyze AI impact" + +# Or run the team directly +go run main.go +` + "```" + ` +` diff --git a/internal/cli/templates/templates.go b/internal/cli/templates/templates.go new file mode 100644 index 0000000..e612617 --- /dev/null +++ b/internal/cli/templates/templates.go @@ -0,0 +1,39 @@ +// Package templates provides project scaffolding templates for gogrid init. +package templates + +// Data holds the template rendering context. +type Data struct { + // Name is the project name. + Name string + // Module is the Go module path (e.g. "github.com/example/myproject"). + Module string +} + +// File represents a single file to generate. +type File struct { + // Path is the relative path within the project directory. + Path string + // Content is the Go text/template string. + Content string +} + +// Template defines a complete project scaffold. +type Template struct { + // Name is the template identifier (e.g. "single", "team", "pipeline"). + Name string + // Description is a short description. + Description string + // Files are the files to generate. + Files []File +} + +var registry = map[string]*Template{} + +func register(t *Template) { + registry[t.Name] = t +} + +// Get returns the template with the given name, or nil. +func Get(name string) *Template { + return registry[name] +} diff --git a/internal/cli/trace.go b/internal/cli/trace.go new file mode 100644 index 0000000..83a2a85 --- /dev/null +++ b/internal/cli/trace.go @@ -0,0 +1,163 @@ +package cli + +import ( + "encoding/json" + "flag" + "fmt" + "strings" + "time" + + "github.com/lonestarx1/gogrid/internal/runrecord" + "github.com/lonestarx1/gogrid/pkg/trace" +) + +func (a *App) runTrace(args []string) int { + fs := flag.NewFlagSet("trace", flag.ContinueOnError) + fs.SetOutput(a.stderr) + jsonOutput := fs.Bool("json", false, "output as JSON") + + if err := fs.Parse(args); err != nil { + return 1 + } + + // No run-id: list recent runs. + if fs.NArg() == 0 { + return a.listRecentRuns() + } + + runID := fs.Arg(0) + rec, err := runrecord.Load(".", runID) + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + + if *jsonOutput { + data, err := json.MarshalIndent(rec.Spans, "", " ") + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + a.outf("%s\n", data) + return 0 + } + + a.renderSpanTree(rec) + return 0 +} + +func (a *App) listRecentRuns() int { + ids, err := runrecord.List(".") + if err != nil { + a.errf("Error: %v\n", err) + return 1 + } + if len(ids) == 0 { + a.outf("No runs found. Run 'gogrid run ' first.\n") + return 0 + } + + a.outf("Recent runs:\n") + limit := 10 + if len(ids) < limit { + limit = len(ids) + } + for _, id := range ids[:limit] { + rec, err := runrecord.Load(".", id) + if err != nil { + a.outf(" %s (error loading)\n", id) + continue + } + errMark := "" + if rec.Error != "" { + errMark = " [ERROR]" + } + a.outf(" %s %s %s %s%s\n", + id, rec.Agent, rec.Model, formatDuration(rec.Duration), errMark) + } + return 0 +} + +func (a *App) renderSpanTree(rec *runrecord.Record) { + a.outf("Run: %s\n", rec.RunID) + a.outf("Agent: %s | Model: %s | Duration: %s\n\n", + rec.Agent, rec.Model, formatDuration(rec.Duration)) + + if len(rec.Spans) == 0 { + a.outf("(no spans recorded)\n") + return + } + + // Build parent-child map. + children := make(map[string][]*trace.Span) + var roots []*trace.Span + for _, s := range rec.Spans { + if s.ParentID == "" { + roots = append(roots, s) + } else { + children[s.ParentID] = append(children[s.ParentID], s) + } + } + + for _, root := range roots { + a.printSpan(root, children, "", true) + } +} + +func (a *App) printSpan(s *trace.Span, children map[string][]*trace.Span, prefix string, isLast bool) { + connector := "\u251c\u2500\u2500 " + if isLast { + connector = "\u2514\u2500\u2500 " + } + if prefix == "" && isLast { + connector = "" + } + + dur := formatDuration(s.EndTime.Sub(s.StartTime)) + detail := spanDetail(s) + a.outf("%s%s%s (%s)%s\n", prefix, connector, s.Name, dur, detail) + + childPrefix := prefix + if prefix != "" || !isLast { + if isLast { + childPrefix += " " + } else { + childPrefix += "\u2502 " + } + } + + kids := children[s.ID] + for i, child := range kids { + a.printSpan(child, children, childPrefix, i == len(kids)-1) + } +} + +func spanDetail(s *trace.Span) string { + var parts []string + if v, ok := s.Attributes["llm.prompt_tokens"]; ok { + parts = append(parts, "prompt: "+v) + } + if v, ok := s.Attributes["llm.completion_tokens"]; ok { + parts = append(parts, "completion: "+v) + } + if v, ok := s.Attributes["tool.name"]; ok { + parts = append(parts, "\""+v+"\"") + } + if s.Error != "" { + parts = append(parts, "ERROR: "+s.Error) + } + if len(parts) == 0 { + return "" + } + return " [" + strings.Join(parts, ", ") + "]" +} + +func formatDuration(d time.Duration) string { + if d < time.Millisecond { + return fmt.Sprintf("%d\u00b5s", d.Microseconds()) + } + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} diff --git a/internal/cli/trace_test.go b/internal/cli/trace_test.go new file mode 100644 index 0000000..a09d52d --- /dev/null +++ b/internal/cli/trace_test.go @@ -0,0 +1,175 @@ +package cli + +import ( + "bytes" + "os" + "strings" + "testing" + "time" + + "github.com/lonestarx1/gogrid/internal/runrecord" + "github.com/lonestarx1/gogrid/pkg/trace" +) + +func saveTestRecord(t *testing.T, dir string) *runrecord.Record { + t.Helper() + now := time.Now() + rec := &runrecord.Record{ + RunID: "test-run-001", + Agent: "researcher", + Model: "claude-sonnet-4-5-20250929", + Provider: "anthropic", + Input: "hello", + Output: "world", + Turns: 2, + Duration: 4200 * time.Millisecond, + Spans: []*trace.Span{ + { + ID: "span-root", + Name: "agent.run", + StartTime: now, + EndTime: now.Add(4200 * time.Millisecond), + }, + { + ID: "span-mem", + ParentID: "span-root", + Name: "memory.load", + StartTime: now, + EndTime: now.Add(1 * time.Millisecond), + }, + { + ID: "span-llm", + ParentID: "span-root", + Name: "llm.complete", + StartTime: now.Add(1 * time.Millisecond), + EndTime: now.Add(2100 * time.Millisecond), + Attributes: map[string]string{ + "llm.prompt_tokens": "150", + "llm.completion_tokens": "89", + }, + }, + { + ID: "span-tool", + ParentID: "span-root", + Name: "tool.execute", + StartTime: now.Add(2100 * time.Millisecond), + EndTime: now.Add(3900 * time.Millisecond), + Attributes: map[string]string{ + "tool.name": "web_search", + }, + }, + }, + } + if err := runrecord.Save(dir, rec); err != nil { + t.Fatal(err) + } + return rec +} + +func TestRunTrace_SpanTree(t *testing.T) { + dir := t.TempDir() + saveTestRecord(t, dir) + + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runTrace([]string{"test-run-001"}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "test-run-001") { + t.Error("expected run ID in output") + } + if !strings.Contains(out, "agent.run") { + t.Error("expected agent.run span") + } + if !strings.Contains(out, "memory.load") { + t.Error("expected memory.load span") + } + if !strings.Contains(out, "llm.complete") { + t.Error("expected llm.complete span") + } + if !strings.Contains(out, "tool.execute") { + t.Error("expected tool.execute span") + } + if !strings.Contains(out, "prompt: 150") { + t.Error("expected prompt token count") + } + if !strings.Contains(out, "web_search") { + t.Error("expected tool name") + } +} + +func TestRunTrace_JSONOutput(t *testing.T) { + dir := t.TempDir() + saveTestRecord(t, dir) + + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runTrace([]string{"-json", "test-run-001"}) + if code != 0 { + t.Fatalf("exit code = %d, want 0\nstderr: %s", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"name"`) { + t.Error("expected JSON output with name field") + } + if !strings.Contains(out, "agent.run") { + t.Error("expected agent.run in JSON") + } +} + +func TestRunTrace_MissingRunID(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runTrace([]string{"nonexistent"}) + if code != 1 { + t.Errorf("exit code = %d, want 1", code) + } +} + +func TestRunTrace_NoArgs_ListRecent(t *testing.T) { + dir := t.TempDir() + saveTestRecord(t, dir) + + orig, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runTrace(nil) + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "test-run-001") { + t.Error("expected run ID in recent runs list") + } +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..432b0ee --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,12 @@ +package cli + +import "runtime" + +// Version is set at build time via -ldflags. +var Version = "dev" + +func (a *App) runVersion() int { + a.outf("gogrid %s (%s/%s, %s)\n", + Version, runtime.GOOS, runtime.GOARCH, runtime.Version()) + return 0 +} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..6161d19 --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "bytes" + "runtime" + "strings" + "testing" +) + +func TestRunVersion_Format(t *testing.T) { + var stdout, stderr bytes.Buffer + app := New(&stdout, &stderr) + + code := app.runVersion() + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + + out := stdout.String() + + // Should contain version string. + if !strings.Contains(out, "gogrid") { + t.Errorf("output %q missing 'gogrid'", out) + } + + // Should contain OS/arch. + if !strings.Contains(out, runtime.GOOS+"/"+runtime.GOARCH) { + t.Errorf("output %q missing OS/ARCH", out) + } + + // Should contain Go version. + if !strings.Contains(out, runtime.Version()) { + t.Errorf("output %q missing Go version", out) + } +} + +func TestRunVersion_DevDefault(t *testing.T) { + var stdout bytes.Buffer + app := New(&stdout, &bytes.Buffer{}) + + app.runVersion() + + if !strings.Contains(stdout.String(), "dev") { + t.Error("expected default version 'dev'") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b4f3ab0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,123 @@ +// Package config handles GoGrid project configuration loading and validation. +package config + +import ( + "fmt" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// validProviders is the set of supported LLM provider names. +var validProviders = map[string]bool{ + "openai": true, + "anthropic": true, + "gemini": true, +} + +// ProjectConfig is the top-level gogrid.yaml structure. +type ProjectConfig struct { + // Version is the config schema version. Must be "1". + Version string `yaml:"version"` + // Agents maps agent names to their configurations. + Agents map[string]AgentConfig `yaml:"agents"` +} + +// AgentConfig defines a single agent's configuration. +type AgentConfig struct { + // Model is the LLM model identifier (e.g. "gpt-4o", "claude-sonnet-4-5-20250929"). + Model string `yaml:"model"` + // Provider is the LLM backend ("openai", "anthropic", or "gemini"). + Provider string `yaml:"provider"` + // Instructions is the agent's system prompt. + Instructions string `yaml:"instructions"` + // Config holds execution parameters. + Config RunConfig `yaml:"config"` +} + +// RunConfig holds agent execution parameters. +type RunConfig struct { + // MaxTurns limits the number of LLM round-trips. 0 means no limit. + MaxTurns int `yaml:"max_turns"` + // MaxTokens limits the LLM response length per turn. + MaxTokens int `yaml:"max_tokens"` + // Temperature controls LLM randomness (0.0-1.0). Nil means provider default. + Temperature *float64 `yaml:"temperature"` + // Timeout is the maximum wall-clock duration for a run (e.g. "60s", "5m"). + Timeout Duration `yaml:"timeout"` + // CostBudget is the maximum cost in USD for a single run. + CostBudget float64 `yaml:"cost_budget"` +} + +// Duration wraps time.Duration with YAML string unmarshaling support. +type Duration struct { + time.Duration +} + +// UnmarshalYAML parses a duration string like "30s" or "5m". +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + if value.Value == "" { + d.Duration = 0 + return nil + } + dur, err := time.ParseDuration(value.Value) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", value.Value, err) + } + d.Duration = dur + return nil +} + +// MarshalYAML writes the duration as a string. +func (d Duration) MarshalYAML() (any, error) { + if d.Duration == 0 { + return "", nil + } + return d.Duration.String(), nil +} + +// Load reads a gogrid.yaml file, performs environment variable substitution, +// parses the YAML, and validates the result. +func Load(path string) (*ProjectConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("config: read %s: %w", path, err) + } + + // Substitute environment variables before parsing. + substituted := Substitute(string(data)) + + var cfg ProjectConfig + if err := yaml.Unmarshal([]byte(substituted), &cfg); err != nil { + return nil, fmt.Errorf("config: parse %s: %w", path, err) + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +// Validate checks that the configuration is well-formed. +func (c *ProjectConfig) Validate() error { + if c.Version != "1" { + return fmt.Errorf("config: unsupported version %q (expected \"1\")", c.Version) + } + if len(c.Agents) == 0 { + return fmt.Errorf("config: at least one agent is required") + } + for name, agent := range c.Agents { + if agent.Model == "" { + return fmt.Errorf("config: agent %q: model is required", name) + } + if agent.Provider == "" { + return fmt.Errorf("config: agent %q: provider is required", name) + } + if !validProviders[agent.Provider] { + return fmt.Errorf("config: agent %q: unsupported provider %q (valid: openai, anthropic, gemini)", name, agent.Provider) + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..ff50280 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,218 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + tests := []struct { + name string + yaml string + env map[string]string + wantErr string + }{ + { + name: "valid config", + yaml: `version: "1" +agents: + researcher: + model: claude-sonnet-4-5-20250929 + provider: anthropic + instructions: You are a researcher. + config: + max_turns: 5 + max_tokens: 4096 + temperature: 0.7 + timeout: 60s + cost_budget: 1.0 +`, + }, + { + name: "multiple agents", + yaml: `version: "1" +agents: + writer: + model: gpt-4o + provider: openai + instructions: You write content. + reviewer: + model: gemini-2.5-pro + provider: gemini + instructions: You review content. +`, + }, + { + name: "env substitution", + yaml: `version: "1" +agents: + test: + model: ${TEST_MODEL} + provider: openai +`, + env: map[string]string{"TEST_MODEL": "gpt-4o-mini"}, + }, + { + name: "env substitution with default", + yaml: `version: "1" +agents: + test: + model: ${TEST_MODEL:-gpt-4o} + provider: openai +`, + }, + { + name: "bad version", + yaml: `version: "2"`, + wantErr: `unsupported version "2"`, + }, + { + name: "missing version", + yaml: `agents: {}`, + wantErr: `unsupported version ""`, + }, + { + name: "no agents", + yaml: `version: "1" +agents: {} +`, + wantErr: "at least one agent is required", + }, + { + name: "missing model", + yaml: `version: "1" +agents: + test: + provider: openai +`, + wantErr: `agent "test": model is required`, + }, + { + name: "missing provider", + yaml: `version: "1" +agents: + test: + model: gpt-4o +`, + wantErr: `agent "test": provider is required`, + }, + { + name: "invalid provider", + yaml: `version: "1" +agents: + test: + model: some-model + provider: invalid +`, + wantErr: `unsupported provider "invalid"`, + }, + { + name: "bad yaml", + yaml: `{{{`, + wantErr: "parse", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + + dir := t.TempDir() + path := filepath.Join(dir, "gogrid.yaml") + if err := os.WriteFile(path, []byte(tt.yaml), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !contains(err.Error(), tt.wantErr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Version != "1" { + t.Errorf("version = %q, want %q", cfg.Version, "1") + } + if len(cfg.Agents) == 0 { + t.Error("expected at least one agent") + } + }) + } +} + +func TestLoad_FileNotFound(t *testing.T) { + _, err := Load("/nonexistent/gogrid.yaml") + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestDuration_Parsing(t *testing.T) { + tests := []struct { + name string + yaml string + wantSec float64 + wantErr bool + }{ + {name: "seconds", yaml: "30s", wantSec: 30}, + {name: "minutes", yaml: "5m", wantSec: 300}, + {name: "complex", yaml: "1m30s", wantSec: 90}, + {name: "empty", yaml: "", wantSec: 0}, + {name: "invalid", yaml: "abc", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfgYAML := `version: "1" +agents: + test: + model: gpt-4o + provider: openai + config: + timeout: ` + tt.yaml + "\n" + + dir := t.TempDir() + path := filepath.Join(dir, "gogrid.yaml") + if err := os.WriteFile(path, []byte(cfgYAML), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := cfg.Agents["test"].Config.Timeout.Seconds() + if got != tt.wantSec { + t.Errorf("timeout = %vs, want %vs", got, tt.wantSec) + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/config/doc.go b/internal/config/doc.go deleted file mode 100644 index 7bfa573..0000000 --- a/internal/config/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package config handles GoGrid configuration loading and validation. -package config diff --git a/internal/config/envsubst.go b/internal/config/envsubst.go new file mode 100644 index 0000000..74a0aab --- /dev/null +++ b/internal/config/envsubst.go @@ -0,0 +1,52 @@ +package config + +import ( + "os" + "strings" +) + +// Substitute replaces ${VAR} and ${VAR:-default} patterns in s with +// environment variable values. If a variable is unset or empty and no +// default is provided, the pattern is replaced with an empty string. +func Substitute(s string) string { + var b strings.Builder + b.Grow(len(s)) + + i := 0 + for i < len(s) { + // Look for "${". + idx := strings.Index(s[i:], "${") + if idx < 0 { + b.WriteString(s[i:]) + break + } + b.WriteString(s[i : i+idx]) + i += idx + 2 // skip past "${" + + // Find closing "}". + end := strings.IndexByte(s[i:], '}') + if end < 0 { + // No closing brace — write the literal "${" and continue. + b.WriteString("${") + continue + } + + expr := s[i : i+end] + i += end + 1 // skip past "}" + + // Check for ":-" default separator. + if sep := strings.Index(expr, ":-"); sep >= 0 { + name := expr[:sep] + def := expr[sep+2:] + if val := os.Getenv(name); val != "" { + b.WriteString(val) + } else { + b.WriteString(def) + } + } else { + b.WriteString(os.Getenv(expr)) + } + } + + return b.String() +} diff --git a/internal/config/envsubst_test.go b/internal/config/envsubst_test.go new file mode 100644 index 0000000..8fbb91e --- /dev/null +++ b/internal/config/envsubst_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "testing" +) + +func TestSubstitute(t *testing.T) { + tests := []struct { + name string + input string + env map[string]string + want string + }{ + { + name: "no patterns", + input: "hello world", + want: "hello world", + }, + { + name: "empty input", + input: "", + want: "", + }, + { + name: "simple var set", + input: "key: ${MY_VAR}", + env: map[string]string{"MY_VAR": "value"}, + want: "key: value", + }, + { + name: "simple var unset", + input: "key: ${UNSET_VAR}", + want: "key: ", + }, + { + name: "default when unset", + input: "key: ${UNSET_VAR:-fallback}", + want: "key: fallback", + }, + { + name: "default not used when set", + input: "key: ${MY_VAR:-fallback}", + env: map[string]string{"MY_VAR": "actual"}, + want: "key: actual", + }, + { + name: "multiple patterns", + input: "${A} and ${B:-two}", + env: map[string]string{"A": "one"}, + want: "one and two", + }, + { + name: "adjacent patterns", + input: "${X}${Y}", + env: map[string]string{"X": "a", "Y": "b"}, + want: "ab", + }, + { + name: "unclosed brace", + input: "key: ${BROKEN", + want: "key: ${BROKEN", + }, + { + name: "empty default", + input: "${VAR:-}", + want: "", + }, + { + name: "default with special chars", + input: "${VAR:-http://localhost:8080}", + want: "http://localhost:8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set env vars for this test. + for k, v := range tt.env { + t.Setenv(k, v) + } + got := Substitute(tt.input) + if got != tt.want { + t.Errorf("Substitute(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/runrecord/record.go b/internal/runrecord/record.go new file mode 100644 index 0000000..1c27677 --- /dev/null +++ b/internal/runrecord/record.go @@ -0,0 +1,103 @@ +// Package runrecord handles persistence of GoGrid agent run results. +// Records are stored as JSON files under .gogrid/runs/. +package runrecord + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/lonestarx1/gogrid/pkg/cost" + "github.com/lonestarx1/gogrid/pkg/llm" + "github.com/lonestarx1/gogrid/pkg/trace" +) + +const runsDir = ".gogrid/runs" + +// Record captures the complete result of a single agent run. +type Record struct { + RunID string `json:"run_id"` + Agent string `json:"agent"` + Model string `json:"model"` + Provider string `json:"provider"` + Input string `json:"input"` + Output string `json:"output"` + Turns int `json:"turns"` + Usage llm.Usage `json:"usage"` + Cost float64 `json:"cost"` + Spans []*trace.Span `json:"spans"` + CostRecords []cost.Record `json:"cost_records"` + StartTime time.Time `json:"start_time"` + Duration time.Duration `json:"duration"` + Error string `json:"error,omitempty"` +} + +// Save persists a record to .gogrid/runs/.json relative to baseDir. +func Save(baseDir string, rec *Record) error { + if rec.RunID == "" { + return fmt.Errorf("runrecord: run ID is required") + } + + dir := filepath.Join(baseDir, runsDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("runrecord: create dir: %w", err) + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return fmt.Errorf("runrecord: marshal: %w", err) + } + + path := filepath.Join(dir, rec.RunID+".json") + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("runrecord: write: %w", err) + } + + return nil +} + +// Load reads a record from .gogrid/runs/.json relative to baseDir. +func Load(baseDir, runID string) (*Record, error) { + path := filepath.Join(baseDir, runsDir, runID+".json") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("runrecord: read %s: %w", runID, err) + } + + var rec Record + if err := json.Unmarshal(data, &rec); err != nil { + return nil, fmt.Errorf("runrecord: unmarshal %s: %w", runID, err) + } + + return &rec, nil +} + +// List returns all run IDs sorted by descending order (newest first). +// IDs are time-sortable, so lexicographic descending order gives newest first. +func List(baseDir string) ([]string, error) { + dir := filepath.Join(baseDir, runsDir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("runrecord: list: %w", err) + } + + var ids []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if ext := filepath.Ext(name); ext == ".json" { + ids = append(ids, name[:len(name)-len(ext)]) + } + } + + sort.Sort(sort.Reverse(sort.StringSlice(ids))) + return ids, nil +} diff --git a/internal/runrecord/record_test.go b/internal/runrecord/record_test.go new file mode 100644 index 0000000..6fc947f --- /dev/null +++ b/internal/runrecord/record_test.go @@ -0,0 +1,171 @@ +package runrecord + +import ( + "testing" + "time" + + "github.com/lonestarx1/gogrid/pkg/cost" + "github.com/lonestarx1/gogrid/pkg/llm" + "github.com/lonestarx1/gogrid/pkg/trace" +) + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + + rec := &Record{ + RunID: "019479a3c4e80001", + Agent: "researcher", + Model: "claude-sonnet-4-5-20250929", + Provider: "anthropic", + Input: "test input", + Output: "test output", + Turns: 2, + Usage: llm.Usage{PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150}, + Cost: 0.001, + Spans: []*trace.Span{ + {ID: "span1", Name: "agent.run", StartTime: time.Now(), EndTime: time.Now()}, + }, + CostRecords: []cost.Record{ + {Model: "claude-sonnet-4-5-20250929", Usage: llm.Usage{PromptTokens: 100}, Cost: 0.001}, + }, + StartTime: time.Now().Truncate(time.Millisecond), + Duration: 2 * time.Second, + } + + if err := Save(dir, rec); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(dir, rec.RunID) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if loaded.RunID != rec.RunID { + t.Errorf("RunID = %q, want %q", loaded.RunID, rec.RunID) + } + if loaded.Agent != rec.Agent { + t.Errorf("Agent = %q, want %q", loaded.Agent, rec.Agent) + } + if loaded.Model != rec.Model { + t.Errorf("Model = %q, want %q", loaded.Model, rec.Model) + } + if loaded.Provider != rec.Provider { + t.Errorf("Provider = %q, want %q", loaded.Provider, rec.Provider) + } + if loaded.Input != rec.Input { + t.Errorf("Input = %q, want %q", loaded.Input, rec.Input) + } + if loaded.Output != rec.Output { + t.Errorf("Output = %q, want %q", loaded.Output, rec.Output) + } + if loaded.Turns != rec.Turns { + t.Errorf("Turns = %d, want %d", loaded.Turns, rec.Turns) + } + if loaded.Usage.PromptTokens != rec.Usage.PromptTokens { + t.Errorf("PromptTokens = %d, want %d", loaded.Usage.PromptTokens, rec.Usage.PromptTokens) + } + if loaded.Cost != rec.Cost { + t.Errorf("Cost = %f, want %f", loaded.Cost, rec.Cost) + } + if len(loaded.Spans) != 1 { + t.Errorf("Spans len = %d, want 1", len(loaded.Spans)) + } + if len(loaded.CostRecords) != 1 { + t.Errorf("CostRecords len = %d, want 1", len(loaded.CostRecords)) + } +} + +func TestSave_MissingID(t *testing.T) { + dir := t.TempDir() + rec := &Record{Agent: "test"} + + err := Save(dir, rec) + if err == nil { + t.Fatal("expected error for missing run ID") + } +} + +func TestLoad_NotFound(t *testing.T) { + dir := t.TempDir() + _, err := Load(dir, "nonexistent") + if err == nil { + t.Fatal("expected error for missing record") + } +} + +func TestSave_AutoCreateDir(t *testing.T) { + dir := t.TempDir() + rec := &Record{RunID: "test-run-001", Agent: "test"} + + if err := Save(dir, rec); err != nil { + t.Fatalf("Save should auto-create .gogrid/runs: %v", err) + } + + // Verify the file exists by loading it. + loaded, err := Load(dir, "test-run-001") + if err != nil { + t.Fatalf("Load after auto-create: %v", err) + } + if loaded.RunID != "test-run-001" { + t.Errorf("RunID = %q, want %q", loaded.RunID, "test-run-001") + } +} + +func TestList(t *testing.T) { + dir := t.TempDir() + + // Empty list when no runs exist. + ids, err := List(dir) + if err != nil { + t.Fatalf("List empty: %v", err) + } + if len(ids) != 0 { + t.Errorf("expected empty list, got %d items", len(ids)) + } + + // Save a few records in non-sorted order. + records := []*Record{ + {RunID: "aaa", Agent: "a"}, + {RunID: "ccc", Agent: "c"}, + {RunID: "bbb", Agent: "b"}, + } + for _, rec := range records { + if err := Save(dir, rec); err != nil { + t.Fatalf("Save %s: %v", rec.RunID, err) + } + } + + ids, err = List(dir) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(ids) != 3 { + t.Fatalf("expected 3 ids, got %d", len(ids)) + } + // Should be sorted descending. + if ids[0] != "ccc" || ids[1] != "bbb" || ids[2] != "aaa" { + t.Errorf("expected [ccc bbb aaa], got %v", ids) + } +} + +func TestSaveAndLoad_WithError(t *testing.T) { + dir := t.TempDir() + rec := &Record{ + RunID: "error-run", + Agent: "test", + Error: "something went wrong", + } + + if err := Save(dir, rec); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(dir, "error-run") + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Error != "something went wrong" { + t.Errorf("Error = %q, want %q", loaded.Error, "something went wrong") + } +} From 12acdaac113e3a83f62d30814f694f4ac188540c Mon Sep 17 00:00:00 2001 From: lonestarx1 Date: Mon, 16 Feb 2026 21:27:56 +0900 Subject: [PATCH 2/3] Add CLI documentation, quickstart example, and README updates Add docs/cli.md with full CLI reference covering all commands, config schema, environment variables, run records, and supported models. Add examples/cli-quickstart/ with a multi-agent gogrid.yaml demonstrating three agents across two providers with env var overrides. Update README.md with Quick Start section, CLI overview with config and trace/cost examples, and links to new docs and examples. --- README.md | 106 ++++++ docs/cli.md | 497 ++++++++++++++++++++++++++++ examples/cli-quickstart/README.md | 166 ++++++++++ examples/cli-quickstart/gogrid.yaml | 71 ++++ 4 files changed, 840 insertions(+) create mode 100644 docs/cli.md create mode 100644 examples/cli-quickstart/README.md create mode 100644 examples/cli-quickstart/gogrid.yaml diff --git a/README.md b/README.md index a00d800..f992fc8 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,108 @@ AI agents are **infrastructure**, not scripts. Python works for prototyping. Go works for production — the same way Kubernetes, Docker, Prometheus, and Terraform are all written in Go. That's why GoGrid is built on Go. +## Quick Start + +Install GoGrid and scaffold a project in under a minute: + +```bash +# Build the CLI +git clone https://github.com/lonestarx1/gogrid.git +cd gogrid +make build + +# Scaffold a new project +bin/gogrid init --template single my-agent +cd my-agent + +# Set up the project +go mod init github.com/example/my-agent +go mod tidy +export OPENAI_API_KEY=sk-proj-... + +# Run your agent +gogrid run assistant -input "Explain Go's concurrency model" +``` + +GoGrid also supports `team` and `pipeline` templates: + +```bash +gogrid init --template team my-research-team +gogrid init --template pipeline my-content-pipeline +``` + +## CLI + +The `gogrid` CLI is the primary interface for working with GoGrid projects. Define agents in `gogrid.yaml`, run them from the command line, and inspect execution traces and costs. + +### Define agents in `gogrid.yaml` + +```yaml +version: "1" + +agents: + researcher: + model: claude-sonnet-4-5-20250929 + provider: anthropic + instructions: | + You are a research assistant. Provide thorough analysis with + key findings, supporting evidence, and areas for further investigation. + config: + max_turns: 10 + max_tokens: 4096 + temperature: 0.7 + timeout: 2m + cost_budget: 0.50 + + summarizer: + model: gpt-4o-mini + provider: openai + instructions: | + Condense the input into 3-5 bullet points. Keep it under 200 words. + config: + max_turns: 3 + max_tokens: 1024 + timeout: 30s +``` + +Config values support environment variable substitution (`${VAR}`, `${VAR:-default}`). API keys are resolved from `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY` environment variables — never stored in config files. + +### Run agents + +```bash +gogrid list # List defined agents +gogrid run researcher -input "Explain the CAP theorem" # Execute an agent +``` + +### Inspect traces and costs + +Every run is recorded. Inspect what happened and what it cost: + +```bash +# View execution span tree +$ gogrid trace +agent.run (4.2s) +├── memory.load (1ms) +├── llm.complete (2.1s) [prompt: 150, completion: 89] +├── llm.complete (1.8s) [prompt: 280, completion: 145] +└── memory.save (2ms) + +# View cost breakdown +$ gogrid cost +MODEL CALLS PROMPT COMPLETION COST +claude-sonnet-4-5-20250929 2 430 234 $0.003280 +──────────────────────────────────────────────────────────────── +TOTAL 2 430 234 $0.003280 +``` + +Both commands support `-json` for programmatic use. Run `gogrid trace` or `gogrid cost` with no arguments to list all recorded runs. + +For full CLI documentation, see [docs/cli.md](docs/cli.md). + ## Features +- **CLI and project scaffolding** — `gogrid init` generates working projects from templates. `gogrid run`, `gogrid trace`, and `gogrid cost` provide a complete development workflow from the command line. +- **YAML-based configuration** — Define agents declaratively with environment variable substitution. No secrets in config files. - **Built-in observability** — Structured tracing with OTLP export (Jaeger, Tempo, Zipkin), structured JSON logging with trace correlation, and Prometheus-compatible metrics — all using the Go standard library - **Cost governance** — Budget alerts, per-entity cost allocation, aggregate reporting, and built-in pricing for popular models. Every LLM call is metered and budgetable. - **Shared memory** — Optimized, monitorable shared memory pool for multi-agent architectures @@ -77,8 +177,14 @@ Python works for prototyping. Go works for production — the same way Kubernete ## Documentation +- [CLI Reference](docs/cli.md) — Full CLI command reference, config format, and environment setup - [Manifesto](docs/manifesto.md) — Why GoGrid exists and what we believe +## Examples + +- [`examples/single-agent/`](examples/single-agent/) — Programmatic single agent with tool use (Go API) +- [`examples/cli-quickstart/`](examples/cli-quickstart/) — Multi-agent project using the CLI with `gogrid.yaml` + ## License Apache License 2.0 — see [LICENSE](LICENSE) for details. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..4b66519 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,497 @@ +# GoGrid CLI Reference + +The GoGrid CLI (`gogrid`) provides commands to scaffold projects, run agents, and inspect execution traces and costs from the command line. It reads agent definitions from a `gogrid.yaml` configuration file and resolves LLM provider credentials from environment variables. + +## Installation + +Build from source: + +```bash +git clone https://github.com/lonestarx1/gogrid.git +cd gogrid +make build +``` + +The binary is written to `bin/gogrid`. Add it to your PATH or run it directly. + +To embed a version string: + +```bash +make build VERSION=1.0.0 +bin/gogrid version +# gogrid 1.0.0 (darwin/arm64, go1.25.4) +``` + +--- + +## Commands + +### `gogrid init` + +Scaffold a new GoGrid project from a template. + +``` +gogrid init [flags] [directory] +``` + +**Flags:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-template` | `single` | Project template: `single`, `team`, or `pipeline` | +| `-name` | directory name | Project name used in generated files | + +**Templates:** + +- **`single`** — A single agent with instructions and configuration. The simplest starting point. +- **`team`** — Two agents (researcher + reviewer) collaborating via shared memory and consensus. +- **`pipeline`** — Two sequential stages (drafter + editor) with state transfer between stages. + +Each template generates four files: `gogrid.yaml`, `main.go`, `Makefile`, and `README.md`. + +**Examples:** + +```bash +# Scaffold a single-agent project in a new directory +gogrid init --template single my-agent + +# Scaffold a team project with a custom name +gogrid init --template team --name research-bot ./research + +# Scaffold a pipeline project in the current (empty) directory +gogrid init --template pipeline +``` + +**What gets generated (single template):** + +``` +my-agent/ + gogrid.yaml # Agent configuration + main.go # Programmatic entry point using GoGrid API + Makefile # Build targets + README.md # Setup instructions +``` + +After scaffolding: + +```bash +cd my-agent +go mod init github.com/example/my-agent +go mod tidy +export OPENAI_API_KEY=sk-... +gogrid run assistant -input "Hello!" +``` + +--- + +### `gogrid list` + +List all agents defined in the project's `gogrid.yaml`. + +``` +gogrid list [flags] +``` + +**Flags:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-config` | `gogrid.yaml` | Path to configuration file | + +**Example:** + +```bash +$ gogrid list +NAME PROVIDER MODEL +researcher anthropic claude-sonnet-4-5-20250929 +summarizer openai gpt-4o-mini +``` + +--- + +### `gogrid run` + +Execute a named agent with the given input. + +``` +gogrid run [flags] +``` + +**Flags:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-config` | `gogrid.yaml` | Path to configuration file | +| `-input` | (required) | Input text to send to the agent | +| `-timeout` | from config | Override the agent's timeout (e.g. `30s`, `5m`) | + +The agent's response is printed to stdout. A run record is saved to `.gogrid/runs/.json` for later inspection with `gogrid trace` and `gogrid cost`. The run ID is printed to stderr. + +**Examples:** + +```bash +# Run an agent with inline input +gogrid run researcher -input "Explain Go's context package" + +# Override timeout +gogrid run summarizer -input "Summarize this paper..." -timeout 2m + +# Use a different config file +gogrid run assistant -config staging.yaml -input "Hello" +``` + +**What happens during a run:** + +1. Loads and validates `gogrid.yaml` +2. Looks up the agent by name +3. Resolves the LLM provider using environment variables (see [Environment Variables](#environment-variables)) +4. Creates the agent with the configured model, instructions, and execution parameters +5. Calls `agent.Run()` with an in-memory tracer to capture spans +6. Prints the agent's response to stdout +7. Saves the full run record (spans, usage, cost) to `.gogrid/runs/` + +--- + +### `gogrid trace` + +Inspect execution traces for agent runs. + +``` +gogrid trace [run-id] [flags] +``` + +**Flags:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-json` | `false` | Output spans as JSON instead of a tree | + +With no arguments, lists the 10 most recent runs. With a run ID, renders the span tree. + +**Examples:** + +```bash +# List recent runs +$ gogrid trace +Recent runs: + 019479a3c4e80001 researcher claude-sonnet-4-5-20250929 4.2s + 019479a1b2c70002 summarizer gpt-4o-mini 1.1s + +# View span tree for a specific run +$ gogrid trace 019479a3c4e80001 +Run: 019479a3c4e80001 +Agent: researcher | Model: claude-sonnet-4-5-20250929 | Duration: 4.2s + +agent.run (4.2s) +├── memory.load (1ms) +├── llm.complete (2.1s) [prompt: 150, completion: 89] +├── tool.execute (1.8s) ["web_search"] +├── llm.complete (0.3s) [prompt: 280, completion: 45] +└── memory.save (2ms) + +# Export as JSON for programmatic use +gogrid trace 019479a3c4e80001 -json +``` + +The span tree shows the hierarchical execution flow: LLM calls with token counts, tool executions, memory operations, and timing for each step. + +--- + +### `gogrid cost` + +View cost breakdown for agent runs. + +``` +gogrid cost [run-id] [flags] +``` + +**Flags:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-json` | `false` | Output cost data as JSON | + +With no arguments, lists all runs with their total cost. With a run ID, shows a per-model cost breakdown. + +**Examples:** + +```bash +# List all runs with costs +$ gogrid cost +RUN ID AGENT MODEL COST +019479a3c4e80001 researcher claude-sonnet-4-5-20250929 $0.003280 +019479a1b2c70002 summarizer gpt-4o-mini $0.000150 + +# Detailed cost breakdown for a run +$ gogrid cost 019479a3c4e80001 +Run: 019479a3c4e80001 + +MODEL CALLS PROMPT COMPLETION COST +claude-sonnet-4-5-20250929 2 430 134 $0.003280 +──────────────────────────────────────────────────────────────── +TOTAL 2 430 134 $0.003280 + +# Export as JSON +gogrid cost 019479a3c4e80001 -json +``` + +--- + +### `gogrid version` + +Print the GoGrid version, platform, and Go version. + +```bash +$ gogrid version +gogrid 1.0.0 (darwin/arm64, go1.25.4) +``` + +--- + +### `gogrid help` + +Show the help message with all available commands. + +```bash +gogrid help +gogrid -h +gogrid --help +``` + +--- + +## Configuration + +GoGrid projects are configured via a `gogrid.yaml` file in the project root. + +### Schema + +```yaml +version: "1" # Required. Config schema version. + +agents: + : # Unique agent identifier. + model: # Required. LLM model ID. + provider: # Required. One of: openai, anthropic, gemini. + instructions: # System prompt for the agent. + config: + max_turns: # Max LLM round-trips. 0 = unlimited. + max_tokens: # Max response tokens per turn. + temperature: # LLM randomness (0.0-1.0). Omit for provider default. + timeout: # Wall-clock limit (e.g. "30s", "5m", "1h"). + cost_budget: # Max cost in USD for a single run. +``` + +### Full Example + +```yaml +version: "1" + +agents: + researcher: + model: claude-sonnet-4-5-20250929 + provider: anthropic + instructions: | + You are a research assistant. When given a topic, provide a thorough + analysis with key findings, supporting evidence, and areas that need + further investigation. + config: + max_turns: 10 + max_tokens: 4096 + temperature: 0.7 + timeout: 2m + cost_budget: 0.50 + + summarizer: + model: gpt-4o-mini + provider: openai + instructions: | + You are a summarizer. Take the provided text and produce a concise + summary that captures the key points in 3-5 bullet points. + config: + max_turns: 3 + max_tokens: 1024 + timeout: 30s + cost_budget: 0.10 + + translator: + model: gemini-2.5-flash + provider: gemini + instructions: | + You are a translator. Translate the input text to the requested + language while preserving tone and meaning. + config: + max_turns: 3 + max_tokens: 4096 + timeout: 30s +``` + +### Environment Variable Substitution + +Config values support `${VAR}` and `${VAR:-default}` syntax for environment variable substitution. This is processed before YAML parsing. + +```yaml +version: "1" + +agents: + assistant: + model: ${MODEL:-gpt-4o-mini} + provider: ${PROVIDER:-openai} + instructions: ${AGENT_INSTRUCTIONS:-You are a helpful assistant.} + config: + max_turns: 10 + timeout: ${TIMEOUT:-60s} +``` + +This lets you change model, provider, or other settings per environment without modifying the config file: + +```bash +MODEL=claude-sonnet-4-5-20250929 PROVIDER=anthropic gogrid run assistant -input "Hello" +``` + +### Validation Rules + +The config is validated on load. The following rules apply: + +- `version` must be `"1"` +- At least one agent must be defined +- Each agent must have `model` and `provider` +- `provider` must be one of: `openai`, `anthropic`, `gemini` + +--- + +## Environment Variables + +The CLI resolves LLM provider credentials from environment variables. No secrets are stored in config files. + +| Provider | Environment Variable | Example | +|----------|---------------------|---------| +| OpenAI | `OPENAI_API_KEY` | `sk-proj-...` | +| Anthropic | `ANTHROPIC_API_KEY` | `sk-ant-...` | +| Gemini | `GEMINI_API_KEY` | `AIza...` | + +Set the variable for whichever provider your agents use: + +```bash +# For OpenAI models +export OPENAI_API_KEY=sk-proj-... + +# For Anthropic models +export ANTHROPIC_API_KEY=sk-ant-... + +# For Google Gemini models +export GEMINI_API_KEY=AIza... + +# Multiple providers at once (for projects with mixed providers) +export OPENAI_API_KEY=sk-proj-... +export ANTHROPIC_API_KEY=sk-ant-... +``` + +--- + +## Run Records + +Every `gogrid run` invocation saves a JSON record to `.gogrid/runs/.json`. Run IDs are time-sortable, so newer runs sort after older ones. + +A run record contains: + +| Field | Description | +|-------|-------------| +| `run_id` | Unique, time-sortable identifier | +| `agent` | Agent name from config | +| `model` | LLM model used | +| `provider` | LLM provider used | +| `input` | User input text | +| `output` | Agent's final response | +| `turns` | Number of LLM round-trips | +| `usage` | Token counts (prompt, completion, total) | +| `cost` | Estimated cost in USD | +| `spans` | Execution trace spans (LLM calls, tool executions, memory operations) | +| `cost_records` | Per-call cost breakdown | +| `start_time` | When the run started | +| `duration` | Wall-clock duration | +| `error` | Error message if the run failed | + +Run records are plain JSON files. You can inspect them directly, back them up, or pipe them to other tools: + +```bash +# View raw record +cat .gogrid/runs/019479a3c4e80001.json | jq . + +# Extract just the cost from all runs +ls .gogrid/runs/*.json | xargs -I{} jq -r '[.run_id, .agent, .cost] | @tsv' {} + +# Total cost across all runs +ls .gogrid/runs/*.json | xargs -I{} jq '.cost' {} | paste -sd+ | bc +``` + +Add `.gogrid/` to your `.gitignore` — run records are local development artifacts: + +``` +# .gitignore +.gogrid/ +``` + +--- + +## Supported Models + +GoGrid includes built-in pricing for cost tracking. Any model string is accepted — these are the ones with pre-configured pricing: + +**OpenAI:** `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `o3`, `o4-mini` + +**Anthropic:** `claude-opus-4-6-20250827`, `claude-opus-4-5-20250620`, `claude-sonnet-4-5-20250929`, `claude-sonnet-4-0-20250514`, `claude-haiku-4-5-20251001` + +**Google Gemini:** `gemini-3-pro`, `gemini-3-flash`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash` + +Models not in this list work fine — cost tracking will report $0.00 until custom pricing is configured via the Go API (`cost.Tracker.SetPricing`). + +--- + +## Typical Workflow + +```bash +# 1. Scaffold a project +gogrid init --template single my-project +cd my-project + +# 2. Set up Go module and dependencies +go mod init github.com/example/my-project +go mod tidy + +# 3. Set your API key +export OPENAI_API_KEY=sk-proj-... + +# 4. List agents +gogrid list + +# 5. Run an agent +gogrid run assistant -input "Explain the CAP theorem in simple terms" + +# 6. Inspect the trace +gogrid trace # list recent runs, copy a run ID +gogrid trace + +# 7. Check costs +gogrid cost +gogrid cost # summary of all runs +``` + +--- + +## Project Structure + +A typical GoGrid project: + +``` +my-project/ + gogrid.yaml # Agent configuration + main.go # Programmatic entry point (optional — for custom logic) + Makefile # Build targets + .gogrid/ + runs/ # Run records (auto-created by gogrid run) + 019479a3c4e8.json + 019479a1b2c7.json +``` + +The CLI and the Go API are complementary. Use the CLI for quick iteration and inspection. Use `main.go` with the Go API when you need tools, custom orchestration patterns, or programmatic control. diff --git a/examples/cli-quickstart/README.md b/examples/cli-quickstart/README.md new file mode 100644 index 0000000..4f86cb4 --- /dev/null +++ b/examples/cli-quickstart/README.md @@ -0,0 +1,166 @@ +# CLI Quickstart Example + +This example demonstrates how to use the GoGrid CLI to define, run, and inspect agents using a `gogrid.yaml` configuration file. + +## Overview + +The `gogrid.yaml` in this directory defines three agents with different roles, providers, and configurations: + +| Agent | Provider | Model | Role | +|-------|----------|-------|------| +| `researcher` | Anthropic | claude-sonnet-4-5-20250929 | In-depth technical analysis | +| `code-reviewer` | OpenAI | gpt-4o-mini | Go code review | +| `summarizer` | OpenAI | gpt-4o-mini | Text summarization | + +## Prerequisites + +1. Build the GoGrid CLI: + +```bash +# From the repository root +make build +``` + +2. Set your API keys for the providers you want to use: + +```bash +# For the researcher agent (Anthropic) +export ANTHROPIC_API_KEY=sk-ant-... + +# For the code-reviewer and summarizer agents (OpenAI) +export OPENAI_API_KEY=sk-proj-... +``` + +## Usage + +All commands below should be run from this directory (`examples/cli-quickstart/`). + +### List agents + +```bash +$ gogrid list +NAME PROVIDER MODEL +code-reviewer openai gpt-4o-mini +researcher anthropic claude-sonnet-4-5-20250929 +summarizer openai gpt-4o-mini +``` + +### Run the researcher agent + +```bash +gogrid run researcher -input "Explain how garbage collection works in Go" +``` + +The agent will respond with a structured analysis. After the response, you'll see a run ID printed to stderr: + +``` +Run ID: 019479a3c4e8... +``` + +### Run the code reviewer agent + +```bash +gogrid run code-reviewer -input "Review this Go function: + +func fetchUser(id string) (*User, error) { + resp, err := http.Get(\"https://api.example.com/users/\" + id) + if err != nil { + return nil, err + } + var user User + json.NewDecoder(resp.Body).Decode(&user) + return &user, nil +}" +``` + +### Run the summarizer agent + +```bash +gogrid run summarizer -input "Go is a statically typed, compiled programming language designed at Google. It is syntactically similar to C, but with memory safety, garbage collection, structural typing, and CSP-style concurrency. It was designed by Robert Griesemer, Rob Pike, and Ken Thompson. Go was publicly announced in November 2009, and version 1.0 was released in March 2012." +``` + +### Inspect execution traces + +After running agents, inspect what happened under the hood: + +```bash +# List recent runs +$ gogrid trace +Recent runs: + 019479a3c4e80001 researcher claude-sonnet-4-5-20250929 4.2s + 019479a1b2c70002 code-reviewer gpt-4o-mini 1.1s + 019479a0a1b60003 summarizer gpt-4o-mini 0.8s + +# View the span tree for a specific run +$ gogrid trace 019479a3c4e80001 +Run: 019479a3c4e80001 +Agent: researcher | Model: claude-sonnet-4-5-20250929 | Duration: 4.2s + +agent.run (4.2s) +├── memory.load (1ms) +├── llm.complete (2.1s) [prompt: 150, completion: 89] +├── llm.complete (1.8s) [prompt: 280, completion: 145] +└── memory.save (2ms) + +# Export as JSON for scripts or other tools +gogrid trace 019479a3c4e80001 -json | jq '.[].name' +``` + +### View cost breakdown + +```bash +# Overview of all runs +$ gogrid cost +RUN ID AGENT MODEL COST +019479a3c4e80001 researcher claude-sonnet-4-5-20250929 $0.003280 +019479a1b2c70002 code-reviewer gpt-4o-mini $0.000150 +019479a0a1b60003 summarizer gpt-4o-mini $0.000090 + +# Detailed breakdown for a specific run +$ gogrid cost 019479a3c4e80001 +Run: 019479a3c4e80001 + +MODEL CALLS PROMPT COMPLETION COST +claude-sonnet-4-5-20250929 2 430 234 $0.003280 +──────────────────────────────────────────────────────────────── +TOTAL 2 430 234 $0.003280 + +# Export as JSON +gogrid cost -json +``` + +## Overriding configuration with environment variables + +The `gogrid.yaml` uses environment variable substitution. You can swap models without editing the config: + +```bash +# Use a different Anthropic model +ANTHROPIC_MODEL=claude-haiku-4-5-20251001 gogrid run researcher -input "What is a goroutine?" + +# Use a different OpenAI model +OPENAI_MODEL=gpt-4o gogrid run code-reviewer -input "Review: func main() { fmt.Println(\"hello\") }" +``` + +## Run records + +All run results are saved as JSON files under `.gogrid/runs/`. You can inspect them directly: + +```bash +# List run records +ls .gogrid/runs/ + +# View a raw record +cat .gogrid/runs/019479a3c4e80001.json | jq . + +# Extract agent names and costs from all runs +for f in .gogrid/runs/*.json; do + echo "$(jq -r '.agent' $f): $(jq -r '.cost' $f)" +done +``` + +## What to explore next + +- **Add tools:** Create a programmatic `main.go` using the GoGrid API to give agents tools (see `examples/single-agent/`) +- **Team orchestration:** Use `gogrid init --template team` to scaffold a multi-agent collaboration project +- **Pipeline orchestration:** Use `gogrid init --template pipeline` to scaffold a sequential processing project +- **Full API docs:** See the Go package documentation for `pkg/agent`, `pkg/orchestrator/team`, `pkg/orchestrator/pipeline`, `pkg/orchestrator/graph`, and `pkg/orchestrator/dynamic` diff --git a/examples/cli-quickstart/gogrid.yaml b/examples/cli-quickstart/gogrid.yaml new file mode 100644 index 0000000..ea26a11 --- /dev/null +++ b/examples/cli-quickstart/gogrid.yaml @@ -0,0 +1,71 @@ +version: "1" + +# This example demonstrates a multi-agent project with three agents across +# two different providers. Each agent has a distinct role, model, and +# execution configuration. +# +# Run agents individually: +# gogrid run researcher -input "Explain how garbage collection works in Go" +# gogrid run code-reviewer -input "Review this function: func add(a, b int) int { return a + b }" +# gogrid run summarizer -input "Go is a statically typed, compiled language..." +# +# Inspect results: +# gogrid trace # list recent runs +# gogrid trace # view execution span tree +# gogrid cost # view costs across all runs +# gogrid cost # detailed cost breakdown for a run + +agents: + # A research agent using Anthropic's Claude for in-depth analysis. + # Uses a higher token limit and more turns for thorough exploration. + researcher: + model: ${ANTHROPIC_MODEL:-claude-sonnet-4-5-20250929} + provider: anthropic + instructions: | + You are a technical researcher. When given a topic: + 1. Explain the core concepts clearly + 2. Provide concrete examples + 3. Discuss trade-offs and alternatives + 4. Mention common pitfalls + + Be thorough but structured. Use headings and bullet points. + config: + max_turns: 10 + max_tokens: 4096 + temperature: 0.7 + timeout: 2m + cost_budget: 0.50 + + # A code review agent using OpenAI's GPT-4o-mini. + # Lower token limit since reviews should be concise. + code-reviewer: + model: ${OPENAI_MODEL:-gpt-4o-mini} + provider: openai + instructions: | + You are a senior Go code reviewer. When given code: + 1. Check for correctness and edge cases + 2. Evaluate error handling + 3. Assess naming and readability + 4. Suggest improvements with code examples + + Be constructive. Praise what's done well before suggesting changes. + Follow Go conventions and idioms. + config: + max_turns: 5 + max_tokens: 2048 + timeout: 60s + cost_budget: 0.10 + + # A summarization agent using a fast, cheap model. + # Minimal turns and low budget since summaries are straightforward. + summarizer: + model: ${OPENAI_MODEL:-gpt-4o-mini} + provider: openai + instructions: | + You are a summarizer. Condense the input into a clear, concise summary. + Use 3-5 bullet points for the key takeaways. Keep it under 200 words. + config: + max_turns: 3 + max_tokens: 1024 + timeout: 30s + cost_budget: 0.05 From d2086e4c3a8956d0941701c8d9d4131cf67b90df Mon Sep 17 00:00:00 2001 From: lonestarx1 Date: Mon, 16 Feb 2026 21:49:27 +0900 Subject: [PATCH 3/3] Add CLI documentation to website docs and examples pages - Add 4 new docs sections: CLI, Configuration (YAML), CLI Commands, Run Records - Add 3 new examples: CLI Multi-Agent Project, Trace & Cost Inspection, Project Scaffolding - Update project structure in Getting Started to include internal/cli/ and internal/config/ --- website/app/docs/page.tsx | 432 +++++++++++++++++++++++++++++++++- website/app/examples/page.tsx | 260 ++++++++++++++++++++ 2 files changed, 691 insertions(+), 1 deletion(-) diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx index ca33a91..edfffd6 100644 --- a/website/app/docs/page.tsx +++ b/website/app/docs/page.tsx @@ -6,6 +6,10 @@ import CodeBlock from "@/components/CodeBlock"; const sections = [ { id: "getting-started", label: "Getting Started" }, + { id: "cli", label: "CLI" }, + { id: "cli-config", label: "Configuration (YAML)" }, + { id: "cli-commands", label: "CLI Commands" }, + { id: "cli-run-records", label: "Run Records" }, { id: "core-types", label: "Core Types" }, { id: "single-agent", label: "Single Agent" }, { id: "providers", label: "LLM Providers" }, @@ -148,13 +152,439 @@ export default function DocsPage() { │ ├── graph/ # Graph orchestrator │ └── dynamic/ # Dynamic orchestration runtime ├── internal/ -│ └── id/ # ID generation +│ ├── id/ # ID generation +│ ├── config/ # YAML configuration loading +│ ├── runrecord/ # Run record persistence +│ └── cli/ # CLI implementation +│ └── templates/ # Project scaffolding templates └── cmd/ └── gogrid/ # CLI entry point`} filename="project layout" /> + {/* CLI */} +
+

+ The gogrid CLI is the primary interface for working with GoGrid + projects. Define agents declaratively in gogrid.yaml, run them + from the command line, and inspect execution traces and costs — all without + writing Go code. +

+

Installation

+

+ Build the CLI from source. The binary is written to bin/gogrid. +

+ +

Quick Start

+

+ Scaffold a project, set an API key, and run your first agent in under a minute. +

+ +

Project Templates

+

+ Three templates are available for gogrid init, each generating + a working project with gogrid.yaml, main.go, + Makefile, and README.md. +

+
+ + + +
+ +
+ + {/* CLI Configuration */} +
+

+ GoGrid projects are configured via a gogrid.yaml file in the + project root. Each agent is defined with a model, provider, system prompt, + and execution parameters. +

+

Schema

+ : # Unique agent identifier. + model: # Required. LLM model ID. + provider: # Required. One of: openai, anthropic, gemini. + instructions: # System prompt for the agent. + config: + max_turns: # Max LLM round-trips. 0 = unlimited. + max_tokens: # Max response tokens per turn. + temperature: # LLM randomness (0.0-1.0). Omit for default. + timeout: # Wall-clock limit (e.g. "30s", "5m", "1h"). + cost_budget: # Max cost in USD for a single run.`} + filename="gogrid.yaml schema" + /> +

Full Example

+ +

Environment Variable Substitution

+

+ Config values support {"\u0024{VAR}"} and {"\u0024{VAR:-default}"} syntax. + This is processed before YAML parsing, so you can override any value per + environment without editing the config file. +

+ + +

Environment Variables

+

+ API keys are resolved from environment variables — never stored in config files. +

+
+ + + +
+

Validation

+

+ The config is validated on load. The CLI will report clear errors for + missing fields, invalid providers, or malformed YAML. +

+
    +
  1. version must be "1"
  2. +
  3. At least one agent must be defined
  4. +
  5. Each agent must have model and provider
  6. +
  7. provider must be one of: openai, anthropic, gemini
  8. +
+
+ + {/* CLI Commands */} +
+

gogrid init

+

+ Scaffold a new GoGrid project from a template. Creates a directory with + gogrid.yaml, main.go, Makefile, and README.md. +

+ + + +

gogrid list

+

+ List all agents defined in the project's gogrid.yaml. +

+ + +

gogrid run

+

+ Execute a named agent with the given input. The agent's response is printed + to stdout. A run record is saved for later inspection with gogrid trace and gogrid cost. +

+ [flags] + +Flags: + -config string Path to config file (default "gogrid.yaml") + -input string Input text to send to the agent (required) + -timeout string Override the agent's timeout (e.g. "30s", "5m")`} + filename="gogrid run" + /> + +

+ What happens during a run: +

+
    +
  1. Loads and validates gogrid.yaml
  2. +
  3. Looks up the agent by name
  4. +
  5. Resolves the LLM provider using environment variables
  6. +
  7. Creates the agent with configured model, instructions, and parameters
  8. +
  9. Calls agent.Run() with an in-memory tracer
  10. +
  11. Prints the response to stdout
  12. +
  13. Saves the run record to .gogrid/runs/
  14. +
+ +

gogrid trace

+

+ Inspect execution traces. With no arguments, lists recent runs. With a run ID, + renders the span tree showing the full execution flow. +

+ + +

gogrid cost

+

+ View cost breakdown for agent runs. With no arguments, lists all runs with + their total cost. With a run ID, shows a per-model cost breakdown. +

+ + +

gogrid version

+ + +

Supported Models

+

+ GoGrid includes built-in pricing for cost tracking. Any model string is + accepted — these have pre-configured pricing: +

+
+ + + +
+

+ Models not in this list work fine — cost tracking will report $0.00 until + custom pricing is configured via the Go API. +

+
+ + {/* Run Records */} +
+

+ Every gogrid run invocation saves a JSON record to + .gogrid/runs/<run-id>.json. Run IDs are time-sortable, so + newer runs always sort after older ones. +

+

Record Fields

+
+ + + + + + + +
+

Inspecting Run Records

+

+ Run records are plain JSON files. You can inspect them directly, back them up, + or pipe them to other tools. +

+ +

+ Add .gogrid/ to your .gitignore — run records are + local development artifacts. +

+

Typical Workflow

+ # view execution span tree + +# 7. Check costs +gogrid cost # detailed breakdown +gogrid cost # summary of all runs`} + filename="terminal" + /> +
+ {/* Core Types */}

diff --git a/website/app/examples/page.tsx b/website/app/examples/page.tsx index 9f63361..ba23dff 100644 --- a/website/app/examples/page.tsx +++ b/website/app/examples/page.tsx @@ -59,6 +59,21 @@ const examples = [ title: "Full Observability Stack", desc: "OTLP export, structured logging, and Prometheus metrics — all wired together.", }, + { + id: "cli-multi-agent", + title: "CLI Multi-Agent Project", + desc: "Define and run multiple agents from YAML — no Go code required.", + }, + { + id: "cli-trace-cost", + title: "CLI Trace & Cost Inspection", + desc: "Inspect execution traces and cost breakdowns from the command line.", + }, + { + id: "cli-scaffold", + title: "Project Scaffolding", + desc: "Scaffold a complete GoGrid project from a template in one command.", + }, ]; export default function ExamplesPage() { @@ -942,6 +957,251 @@ func main() { /> + {/* CLI Multi-Agent Project */} + + + + + + {/* CLI Trace & Cost Inspection */} + + + + + + {/* Project Scaffolding */} + + + + + + + + {/* Full Observability Stack */}