From e411e07eb54d6c95ff90aa642d825679e5653d59 Mon Sep 17 00:00:00 2001 From: ATMackay Date: Fri, 13 Mar 2026 20:27:32 +1100 Subject: [PATCH 1/7] feat(agents): Add documentation agent v1 --- .github/workflows/go.yml | 46 ++++++ .gitignore | 5 + Makefile | 2 +- agents/documentor/documentor.go | 237 ++++++++++++++++++++++++++---- agents/documentor/files.go | 15 ++ agents/documentor/prompts.go | 67 +++++++++ agents/documentor/repo.go | 249 ++++++++++++++++++++++++++++++++ agents/documentor/types.go | 48 ++++++ agents/documentor/workflow.go | 28 ++++ cmd/cmd.go | 2 +- cmd/documentor.go | 116 ++++++++++++++- constants/version.go | 10 +- go.mod | 1 + 13 files changed, 792 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/go.yml create mode 100644 .gitignore create mode 100644 agents/documentor/files.go create mode 100644 agents/documentor/prompts.go create mode 100644 agents/documentor/repo.go create mode 100644 agents/documentor/types.go create mode 100644 agents/documentor/workflow.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..8a23238 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,46 @@ +# Alex Mackay 2026 +# Golang CI with GitHub Actions +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.0' + + - name: build + run: go build -v ./... + + - name: unit-test + run: go test -v ./... + + golangci: + runs-on: ubuntu-latest + name: lint + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + check-latest: true + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + install-mode: goinstall + version: latest + args: --timeout=2m diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a8856d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.out +agent +build/* +docs/generated +data \ No newline at end of file diff --git a/Makefile b/Makefile index 98af407..ab7f937 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ build: @mkdir -p build @echo ">> building $(BIN) (version=$(VERSION_TAG) commit=$(GIT_COMMIT) dirty=$(DIRTY))" GO111MODULE=on go build -ldflags "$(LDFLAGS)" -o $(BIN) - @echo "Checkout server successfully built. To run the application execute './$(BIN) run'" + @echo "Agent server successfully built. To run the application execute './$(BIN) run'" install: build mv $(BIN) $(GOBIN) diff --git a/agents/documentor/documentor.go b/agents/documentor/documentor.go index baf90b7..c2f9119 100644 --- a/agents/documentor/documentor.go +++ b/agents/documentor/documentor.go @@ -2,44 +2,231 @@ package documentor import ( "context" - "log" - - "google.golang.org/genai" + "encoding/json" + "fmt" + "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/model/gemini" + "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" + "google.golang.org/genai" ) -type Documentor struct { - // TODO - +type Config struct { + ModelName string + APIKey string + WorkDir string } -func NewDocumentorAgent(ctx context.Context) (*Documentor, error) { - model, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{}) +func NewDocumentorAgent(ctx context.Context, cfg Config) (agent.Agent, error) { + if cfg.ModelName == "" { + cfg.ModelName = "gemini-2.5-pro" + } + if cfg.APIKey == "" { + return nil, fmt.Errorf("API key is required") + } + + model, err := gemini.NewModel(ctx, cfg.ModelName, &genai.ClientConfig{ + APIKey: cfg.APIKey, + }) if err != nil { - log.Fatalf("failed to create model: %s", err) + return nil, fmt.Errorf("create model: %w", err) } - // Copied from ADK examples/workflows + fetchRepoTreeTool, err := functiontool.New( + functiontool.Config{ + Name: "fetch_repo_tree", + Description: "Download the GitHub repository to a local cache, build a source-file manifest, and store both in state.", + }, + newFetchRepoTreeTool(cfg), + ) + if err != nil { + return nil, fmt.Errorf("create fetch_repo_tree tool: %w", err) + } - // --- 1. Define Sub-Agents for Each Pipeline Stage --- + readRepoFileTool, err := functiontool.New( + functiontool.Config{ + Name: "read_repo_file", + Description: "Read a repository file from the cached checkout and store it in state.", + }, + newReadRepoFileTool(), + ) + if err != nil { + return nil, fmt.Errorf("create read_repo_file tool: %w", err) + } - // Code Writer Agent - // Takes the initial specification (from user query) and writes code. - _, err = llmagent.New(llmagent.Config{ - Name: "CodeWriterAgent", - Model: model, - Instruction: `You are a Python Code Generator. -Based *only* on the user's request, write Python code that fulfills the requirement. -Output *only* the complete Python code block, enclosed in triple backticks ('''python ... '''). -Do not add any other text before or after the code block.`, - Description: "Writes initial Python code based on a specification.", - OutputKey: "generated_code", // Stores output in state["generated_code"] - }) + writeOutputTool, err := functiontool.New( + functiontool.Config{ + Name: "write_output_file", + Description: "Write markdown documentation to the requested output file.", + }, + newWriteOutputFileTool(), + ) if err != nil { - log.Fatalf("failed to create codeWriterAgent: %s", err) + return nil, fmt.Errorf("create write_output_file tool: %w", err) + } + + return llmagent.New(llmagent.Config{ + Name: "documentor", + Model: model, + Description: "Retrieves code from a GitHub repository and writes high-quality markdown documentation.", + Instruction: buildInstruction(), + Tools: []tool.Tool{ + fetchRepoTreeTool, + readRepoFileTool, + writeOutputTool, + }, + OutputKey: StateDocumentation, + }) +} + +func buildInstruction() string { + return ` +You are a code documentation agent. + +Repository: {repo_url} +Ref: {repo_ref?} +Sub-path filter: {sub_path?} +Output path: {output_path} +Max files to read: {max_files?} + +Workflow: +1. Call fetch_repo_tree first using the repository_url, ref, and sub_path from state. +2. Inspect the manifest and identify the most relevant files for architecture and code-level documentation. +3. Prefer entry points, cmd/, internal/, pkg/, config, and core domain files. +4. Skip tests, generated files, vendor, binaries, and irrelevant assets unless they are central. +5. Do not read more than max_files files. +6. Call read_repo_file for each selected file. +7. Write detailed maintainers' documentation in markdown. +8. Call write_output_file with the completed markdown and output_path. + +Requirements: +- Explain architecture and package responsibilities. +- Explain key types, functions, interfaces, and control flow. +- Explain configuration, dependencies, and extension points. +- Mention important file paths and symbol names. +- Do not invent behavior beyond the code retrieved. +- If repository coverage is partial, say so explicitly. +` +} + +type FetchRepoTreeArgs struct { + RepositoryURL string `json:"repository_url"` + Ref string `json:"ref,omitempty"` + SubPath string `json:"sub_path,omitempty"` +} + +type FileEntry struct { + Path string `json:"path"` + Kind string `json:"kind"` + Size int64 `json:"size,omitempty"` +} + +type FetchRepoTreeResult struct { + FileCount int `json:"file_count"` + Manifest []FileEntry `json:"manifest"` +} + +func newFetchRepoTreeTool(cfg Config) func(tool.Context, FetchRepoTreeArgs) (FetchRepoTreeResult, error) { + return func(ctx tool.Context, args FetchRepoTreeArgs) (FetchRepoTreeResult, error) { + localPath, manifest, err := fetchRepoManifest(args.RepositoryURL, args.Ref, args.SubPath, cfg.WorkDir) + if err != nil { + return FetchRepoTreeResult{}, err + } + + raw, err := json.Marshal(manifest) + if err != nil { + return FetchRepoTreeResult{}, err + } + + ctx.Actions().StateDelta[StateRepoURL] = args.RepositoryURL + ctx.Actions().StateDelta[StateRepoRef] = args.Ref + ctx.Actions().StateDelta[StateSubPath] = args.SubPath + ctx.Actions().StateDelta[StateRepoManifest] = string(raw) + ctx.Actions().StateDelta[StateRepoLocalPath] = localPath + + return FetchRepoTreeResult{ + FileCount: len(manifest), + Manifest: manifest, + }, nil + } +} + +type ReadRepoFileArgs struct { + Path string `json:"path"` +} + +type ReadRepoFileResult struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func newReadRepoFileTool() func(tool.Context, ReadRepoFileArgs) (ReadRepoFileResult, error) { + return func(ctx tool.Context, args ReadRepoFileArgs) (ReadRepoFileResult, error) { + v, err := ctx.State().Get(StateRepoLocalPath) + if err != nil { + return ReadRepoFileResult{}, fmt.Errorf("read repo local path from state: %w", err) + } + + localPath, ok := v.(string) + if !ok || localPath == "" { + return ReadRepoFileResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") + } + + content, err := readRepoFileFromCachedCheckout(localPath, args.Path) + if err != nil { + return ReadRepoFileResult{}, err + } + + loaded := map[string]string{} + existing, err := ctx.State().Get(StateLoadedFiles) + if err == nil && existing != nil { + if s, ok := existing.(string); ok && s != "" { + _ = json.Unmarshal([]byte(s), &loaded) + } + } + + loaded[args.Path] = content + raw, _ := json.Marshal(loaded) + ctx.Actions().StateDelta[StateLoadedFiles] = string(raw) + + return ReadRepoFileResult{ + Path: args.Path, + Content: content, + }, nil } +} - return &Documentor{}, nil +type WriteOutputFileArgs struct { + Markdown string `json:"markdown"` + OutputPath string `json:"output_path,omitempty"` +} + +type WriteOutputFileResult struct { + Path string `json:"path"` +} + +func newWriteOutputFileTool() func(tool.Context, WriteOutputFileArgs) (WriteOutputFileResult, error) { + return func(ctx tool.Context, args WriteOutputFileArgs) (WriteOutputFileResult, error) { + out := args.OutputPath + if out == "" { + v, err := ctx.State().Get(StateOutputPath) + if err == nil { + if s, ok := v.(string); ok { + out = s + } + } + } + if out == "" { + return WriteOutputFileResult{}, fmt.Errorf("output path is required") + } + + if err := writeTextFile(out, args.Markdown); err != nil { + return WriteOutputFileResult{}, err + } + + ctx.Actions().StateDelta[StateDocumentation] = args.Markdown + return WriteOutputFileResult{Path: out}, nil + } } diff --git a/agents/documentor/files.go b/agents/documentor/files.go new file mode 100644 index 0000000..fb66dbf --- /dev/null +++ b/agents/documentor/files.go @@ -0,0 +1,15 @@ +package documentor + +import ( + "fmt" + "os" + "path/filepath" +) + +// writeTextFile creates parent directories as needed and writes content to path. +func writeTextFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + return os.WriteFile(path, []byte(content), 0o644) +} diff --git a/agents/documentor/prompts.go b/agents/documentor/prompts.go new file mode 100644 index 0000000..0ea3dfb --- /dev/null +++ b/agents/documentor/prompts.go @@ -0,0 +1,67 @@ +package documentor + +import ( + "fmt" + "strings" +) + +func buildAnalysisPrompt(state State) string { + var b strings.Builder + + fmt.Fprintf(&b, "Analyze this repository and produce architecture notes.\n\n") + fmt.Fprintf(&b, "Repository: %s\n", state.Repo.URL) + fmt.Fprintf(&b, "Ref: %s\n\n", state.Repo.Ref) + + fmt.Fprintf(&b, "Manifest:\n") + for _, f := range state.Manifest { + fmt.Fprintf(&b, "- %s (lang=%s, pkg=%s, entry=%t)\n", f.Path, f.Language, f.PackageName, f.IsEntry) + } + + fmt.Fprintf(&b, "\nSelected file contents:\n") + for _, f := range state.Selected { + fmt.Fprintf(&b, "\n===== FILE: %s =====\n%s\n", f.Path, f.Content) + } + + fmt.Fprintf(&b, ` +Write architecture notes covering: +- purpose of the repo/subsystem +- main packages/modules +- entry points +- key types/functions/interfaces +- config flow +- error handling patterns +- extension points +- unclear areas / TODOs + +Be precise. Do not invent behavior. +`) + return b.String() +} + +func buildWriterPrompt(state State) string { + var b strings.Builder + + fmt.Fprintf(&b, "Write high-quality maintainer documentation as markdown.\n\n") + fmt.Fprintf(&b, "Repository: %s\n", state.Repo.URL) + fmt.Fprintf(&b, "Ref: %s\n\n", state.Repo.Ref) + + fmt.Fprintf(&b, "Architecture notes:\n%s\n\n", state.AnalysisMD) + + fmt.Fprintf(&b, ` +Output requirements: +- Title +- Executive summary +- Architecture overview +- Package/module breakdown +- Key types and functions +- Execution/control flow +- Configuration and dependencies +- Extension points +- Operational notes / caveats +- File index of the most important files + +Include file paths and symbol names where possible. +Do not include unsupported claims. +`) + return b.String() +} diff --git a/agents/documentor/repo.go b/agents/documentor/repo.go new file mode 100644 index 0000000..500fff3 --- /dev/null +++ b/agents/documentor/repo.go @@ -0,0 +1,249 @@ +package documentor + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const maxReadBytes = 128 * 1024 + +func fetchRepoManifest(repoURL, ref, subPath, workDir string) (string, []FileEntry, error) { + owner, repo, err := parseGitHubRepoURL(repoURL) + if err != nil { + return "", nil, err + } + + root, err := downloadAndExtractGitHubRepo(owner, repo, ref, workDir) + if err != nil { + return "", nil, err + } + + if subPath != "" { + root = filepath.Join(root, filepath.Clean(subPath)) + info, err := os.Stat(root) + if err != nil { + return "", nil, fmt.Errorf("sub_path not found: %w", err) + } + if !info.IsDir() { + return "", nil, fmt.Errorf("sub_path is not a directory: %s", subPath) + } + } + + manifest, err := buildManifest(root) + if err != nil { + return "", nil, err + } + + return root, manifest, nil +} + +func parseGitHubRepoURL(repoURL string) (string, string, error) { + u, err := url.Parse(repoURL) + if err != nil { + return "", "", fmt.Errorf("invalid repository URL: %w", err) + } + if !strings.EqualFold(u.Host, "github.com") && !strings.EqualFold(u.Host, "www.github.com") { + return "", "", fmt.Errorf("only github.com repositories are supported") + } + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + return "", "", fmt.Errorf("repository URL must look like https://github.com/{owner}/{repo}") + } + + owner := parts[0] + repo := strings.TrimSuffix(parts[1], ".git") + if owner == "" || repo == "" { + return "", "", fmt.Errorf("invalid GitHub repository URL") + } + return owner, repo, nil +} + +func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, error) { + if workDir == "" { + workDir = os.TempDir() + } + + dest, err := os.MkdirTemp(workDir, "repo-*") + if err != nil { + return "", err + } + + archiveURL := fmt.Sprintf("https://codeload.github.com/%s/%s/tar.gz", owner, repo) + if strings.TrimSpace(ref) != "" { + archiveURL = fmt.Sprintf("https://codeload.github.com/%s/%s/tar.gz/%s", owner, repo, url.PathEscape(ref)) + } + + req, err := http.NewRequest(http.MethodGet, archiveURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "agent-documentor") + + client := &http.Client{Timeout: 90 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("download repository archive: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download repository archive failed: %s", resp.Status) + } + + if err := untarGz(resp.Body, dest); err != nil { + return "", err + } + + root, err := firstSubdir(dest) + if err != nil { + return "", err + } + return root, nil +} + +func untarGz(r io.Reader, dest string) error { + gzr, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + + target := filepath.Join(dest, filepath.Clean(hdr.Name)) + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + } + } +} + +func firstSubdir(root string) (string, error) { + entries, err := os.ReadDir(root) + if err != nil { + return "", err + } + for _, e := range entries { + if e.IsDir() { + return filepath.Join(root, e.Name()), nil + } + } + return "", fmt.Errorf("no extracted repository directory found") +} + +func buildManifest(root string) ([]FileEntry, error) { + var manifest []FileEntry + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + + if info.IsDir() { + if shouldSkipDir(rel) { + return filepath.SkipDir + } + return nil + } + + if !shouldIncludeFile(rel, info.Size()) { + return nil + } + + manifest = append(manifest, FileEntry{ + Path: filepath.ToSlash(rel), + Kind: "file", + Size: info.Size(), + }) + return nil + }) + if err != nil { + return nil, err + } + + return manifest, nil +} + +func readRepoFileFromCachedCheckout(localRoot, relPath string) (string, error) { + cleanRel := filepath.Clean(relPath) + fullPath := filepath.Join(localRoot, cleanRel) + + if !strings.HasPrefix(fullPath, filepath.Clean(localRoot)+string(os.PathSeparator)) && + filepath.Clean(fullPath) != filepath.Clean(localRoot) { + return "", fmt.Errorf("invalid repository path: %s", relPath) + } + + b, err := os.ReadFile(fullPath) + if err != nil { + return "", fmt.Errorf("read repository file %s: %w", relPath, err) + } + + if len(b) > maxReadBytes { + b = b[:maxReadBytes] + } + return string(b), nil +} + +func shouldSkipDir(rel string) bool { + switch filepath.Base(rel) { + case ".git", ".github", "vendor", "node_modules", "dist", "build", "bin": + return true + default: + return false + } +} + +func shouldIncludeFile(rel string, size int64) bool { + if size <= 0 || size > 512*1024 { + return false + } + + switch strings.ToLower(filepath.Ext(rel)) { + case ".go", ".md", ".txt", ".yaml", ".yml", ".json", ".toml", ".proto", ".sql", ".sh": + return true + default: + return false + } +} diff --git a/agents/documentor/types.go b/agents/documentor/types.go new file mode 100644 index 0000000..17e4a67 --- /dev/null +++ b/agents/documentor/types.go @@ -0,0 +1,48 @@ +package documentor + +// Session state keys used by the documentor agent. +const ( + StateRepoURL = "repo_url" + StateRepoRef = "repo_ref" + StateSubPath = "sub_path" + StateOutputPath = "output_path" + StateMaxFiles = "max_files" + + StateRepoManifest = "temp_repo_manifest" + StateRepoLocalPath = "temp_repo_local_path" + StateLoadedFiles = "temp_loaded_files" + + StateDocumentation = "documentation_markdown" +) + +type FileInfo struct { + Path string + Size int64 + Language string + PackageName string + IsEntry bool + IsTest bool + IsGenerated bool +} + +type SourceFile struct { + Path string + Content string +} + +type RepoMetadata struct { + Owner string + Name string + Ref string + URL string +} + +type State struct { + Repo RepoMetadata + LocalPath string + OutputPath string + Manifest []FileInfo + Selected []SourceFile + AnalysisMD string + Documentation string +} diff --git a/agents/documentor/workflow.go b/agents/documentor/workflow.go new file mode 100644 index 0000000..9bc562e --- /dev/null +++ b/agents/documentor/workflow.go @@ -0,0 +1,28 @@ +package documentor + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// ensureClone clones repoURL into workDir if it has not been cloned yet. +func ensureClone(repoURL, ref, workDir string) error { + if _, err := os.Stat(filepath.Join(workDir, ".git")); err == nil { + return nil // already cloned + } + + args := []string{"clone", "--depth=1"} + if ref != "" { + args = append(args, "--branch", ref) + } + args = append(args, repoURL, ".") + + cmd := exec.Command("git", args...) + cmd.Dir = workDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) + } + return nil +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 5538b42..a8bb746 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -1,9 +1,9 @@ package cmd import ( - "code-agent/constants" "fmt" + "github.com/ATMackay/agent/constants" "github.com/spf13/cobra" ) diff --git a/cmd/documentor.go b/cmd/documentor.go index 50eba46..301cbeb 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -1,7 +1,119 @@ package cmd -import "github.com/spf13/cobra" +import ( + "fmt" + "os" + + agentpkg "google.golang.org/adk/agent" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" + "google.golang.org/genai" + + "github.com/ATMackay/agent/agents/documentor" + "github.com/spf13/cobra" +) func NewDocumentorCmd() *cobra.Command { - // TODO + var repoURL string + var ref string + var pathPrefix string + var output string + var maxFiles int + var model string + + cmd := &cobra.Command{ + Use: "documentor", + Short: "Run the code documentation agent", + RunE: func(cmd *cobra.Command, args []string) error { + apiKey := os.Getenv("GOOGLE_API_KEY") + if apiKey == "" { + return fmt.Errorf("GOOGLE_API_KEY is required") + } + if repoURL == "" { + return fmt.Errorf("--repo is required") + } + if output == "" { + return fmt.Errorf("--output is required") + } + + workDir, err := os.MkdirTemp("", "agent-documentor-*") + if err != nil { + return fmt.Errorf("create work dir: %w", err) + } + defer os.RemoveAll(workDir) + + ctx := cmd.Context() + + doc, err := documentor.NewDocumentorAgent(ctx, documentor.Config{ + ModelName: model, + APIKey: apiKey, + WorkDir: workDir, + }) + if err != nil { + return fmt.Errorf("create agent: %w", err) + } + + sessService := session.InMemoryService() + r, err := runner.New(runner.Config{ + AppName: "documentor", + Agent: doc, + SessionService: sessService, + }) + if err != nil { + return fmt.Errorf("create runner: %w", err) + } + + initState := map[string]any{ + documentor.StateRepoURL: repoURL, + documentor.StateRepoRef: ref, + documentor.StateOutputPath: output, + documentor.StateMaxFiles: maxFiles, + } + if pathPrefix != "" { + initState[documentor.StateSubPath] = pathPrefix + } + + resp, err := sessService.Create(ctx, &session.CreateRequest{ + AppName: "documentor", + UserID: "cli-user", + State: initState, + }) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + + userMsg := &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + Text: "Generate detailed code documentation for the configured repository. " + + "Use fetch_repo_tree first, then read relevant files, then write the markdown output file.", + }, + }, + } + + for event, err := range r.Run(ctx, "cli-user", resp.Session.ID(), userMsg, agentpkg.RunConfig{}) { + if err != nil { + return fmt.Errorf("agent error: %w", err) + } + _ = event + } + + if _, err := os.Stat(output); err != nil { + return fmt.Errorf("agent finished but output file was not created: %w", err) + } + + fmt.Printf("Documentation written to %s\n", output) + return nil + }, + } + + cmd.Flags().StringVar(&repoURL, "repo", "", "GitHub repository URL") + cmd.Flags().StringVar(&ref, "ref", "", "Optional branch, tag, or commit") + cmd.Flags().StringVar(&pathPrefix, "path", "", "Optional subdirectory to document") + cmd.Flags().StringVar(&output, "output", "", "Output file path for the generated markdown") + cmd.Flags().IntVar(&maxFiles, "max-files", 20, "Maximum number of files to read") + cmd.Flags().StringVar(&model, "model", "gemini-2.5-pro", "Gemini model to use") + + return cmd } diff --git a/constants/version.go b/constants/version.go index 08e32d4..6d57196 100644 --- a/constants/version.go +++ b/constants/version.go @@ -4,9 +4,9 @@ var ( // // https://icinga.com/blog/2022/05/25/embedding-git-commit-information-in-go-binaries/ // - Version = "0.0.0" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.Version=$version'" - CommitDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.CommitDate=$commit_date'" - GitCommit = "" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.GitCommit=$commit_hash'" - BuildDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.BuildDate=$build_date'" - Dirty = "false" // overwritten by -ldflag "-X 'github.com/ATMackay/checkout/constants.Dirty=$dirty'" + Version = "0.0.0" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.Version=$version'" + CommitDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.CommitDate=$commit_date'" + GitCommit = "" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.GitCommit=$commit_hash'" + BuildDate = "" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.BuildDate=$build_date'" + Dirty = "false" // overwritten by -ldflag "-X 'github.com/ATMackay/agent/constants.Dirty=$dirty'" ) diff --git a/go.mod b/go.mod index 08807b6..0809282 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/safehtml v0.1.0 // indirect github.com/google/uuid v1.6.0 // indirect From b5c86a258f5852c07d54923d96ee3cadd052d5d5 Mon Sep 17 00:00:00 2001 From: ATMackay Date: Sat, 14 Mar 2026 01:13:53 +1100 Subject: [PATCH 2/7] lint --- Makefile | 6 +- agents/documentor/config.go | 18 ++++ agents/documentor/documentor.go | 167 +++----------------------------- agents/documentor/prompts.go | 92 ++++++------------ agents/documentor/repo.go | 12 ++- agents/documentor/tools.go | 128 ++++++++++++++++++++++++ agents/documentor/types.go | 32 ------ agents/documentor/workflow.go | 28 ------ cmd/documentor.go | 46 +++++++-- cmd/run.go | 11 ++- 10 files changed, 246 insertions(+), 294 deletions(-) create mode 100644 agents/documentor/config.go create mode 100644 agents/documentor/tools.go delete mode 100644 agents/documentor/workflow.go diff --git a/Makefile b/Makefile index ab7f937..62ccc7b 100644 --- a/Makefile +++ b/Makefile @@ -5,10 +5,10 @@ BUILD_FOLDER = build COVERAGE_BUILD_FOLDER ?= $(BUILD_FOLDER)/coverage UNIT_COVERAGE_OUT ?= $(COVERAGE_BUILD_FOLDER)/ut_cov.out -BIN ?= $(BUILD_FOLDER)/checkout +BIN ?= $(BUILD_FOLDER)/agent # Packages -PKG ?= github.com/ATMackay/checkout +PKG ?= github.com/ATMackay/agent CONSTANTS_PKG ?= $(PKG)/constants @@ -31,7 +31,7 @@ install: build mv $(BIN) $(GOBIN) run: build - @./$(BUILD_FOLDER)/agents run --documentation --demo + @./$(BUILD_FOLDER)/agents run documentor --repo https://github.com/ATMackay/agent.git build/coverage: @mkdir -p $(COVERAGE_BUILD_FOLDER) diff --git a/agents/documentor/config.go b/agents/documentor/config.go new file mode 100644 index 0000000..315007f --- /dev/null +++ b/agents/documentor/config.go @@ -0,0 +1,18 @@ +package documentor + +import "errors" + +// Config is the base config struct for documentation agent +type Config struct { + ModelName string + APIKey string + WorkDir string +} + +func (c Config) Validate() error { + // model name & work dir use defaults + if c.APIKey == "" { + return errors.New("missing API key") + } + return nil +} diff --git a/agents/documentor/documentor.go b/agents/documentor/documentor.go index c2f9119..974bc80 100644 --- a/agents/documentor/documentor.go +++ b/agents/documentor/documentor.go @@ -2,7 +2,6 @@ package documentor import ( "context" - "encoding/json" "fmt" "google.golang.org/adk/agent" @@ -13,13 +12,12 @@ import ( "google.golang.org/genai" ) -type Config struct { - ModelName string - APIKey string - WorkDir string +type Documentor struct { + inner agent.Agent } -func NewDocumentorAgent(ctx context.Context, cfg Config) (agent.Agent, error) { +// NewDocumentorAgent returns a Documentor. +func NewDocumentorAgent(ctx context.Context, cfg Config) (*Documentor, error) { if cfg.ModelName == "" { cfg.ModelName = "gemini-2.5-pro" } @@ -67,166 +65,27 @@ func NewDocumentorAgent(ctx context.Context, cfg Config) (agent.Agent, error) { return nil, fmt.Errorf("create write_output_file tool: %w", err) } - return llmagent.New(llmagent.Config{ + // Instantiate LLM agent + da, err := llmagent.New(llmagent.Config{ Name: "documentor", Model: model, Description: "Retrieves code from a GitHub repository and writes high-quality markdown documentation.", Instruction: buildInstruction(), Tools: []tool.Tool{ - fetchRepoTreeTool, + fetchRepoTreeTool, // Fetch Git Repository files readRepoFileTool, writeOutputTool, }, OutputKey: StateDocumentation, }) -} - -func buildInstruction() string { - return ` -You are a code documentation agent. - -Repository: {repo_url} -Ref: {repo_ref?} -Sub-path filter: {sub_path?} -Output path: {output_path} -Max files to read: {max_files?} - -Workflow: -1. Call fetch_repo_tree first using the repository_url, ref, and sub_path from state. -2. Inspect the manifest and identify the most relevant files for architecture and code-level documentation. -3. Prefer entry points, cmd/, internal/, pkg/, config, and core domain files. -4. Skip tests, generated files, vendor, binaries, and irrelevant assets unless they are central. -5. Do not read more than max_files files. -6. Call read_repo_file for each selected file. -7. Write detailed maintainers' documentation in markdown. -8. Call write_output_file with the completed markdown and output_path. - -Requirements: -- Explain architecture and package responsibilities. -- Explain key types, functions, interfaces, and control flow. -- Explain configuration, dependencies, and extension points. -- Mention important file paths and symbol names. -- Do not invent behavior beyond the code retrieved. -- If repository coverage is partial, say so explicitly. -` -} - -type FetchRepoTreeArgs struct { - RepositoryURL string `json:"repository_url"` - Ref string `json:"ref,omitempty"` - SubPath string `json:"sub_path,omitempty"` -} - -type FileEntry struct { - Path string `json:"path"` - Kind string `json:"kind"` - Size int64 `json:"size,omitempty"` -} - -type FetchRepoTreeResult struct { - FileCount int `json:"file_count"` - Manifest []FileEntry `json:"manifest"` -} - -func newFetchRepoTreeTool(cfg Config) func(tool.Context, FetchRepoTreeArgs) (FetchRepoTreeResult, error) { - return func(ctx tool.Context, args FetchRepoTreeArgs) (FetchRepoTreeResult, error) { - localPath, manifest, err := fetchRepoManifest(args.RepositoryURL, args.Ref, args.SubPath, cfg.WorkDir) - if err != nil { - return FetchRepoTreeResult{}, err - } - - raw, err := json.Marshal(manifest) - if err != nil { - return FetchRepoTreeResult{}, err - } - - ctx.Actions().StateDelta[StateRepoURL] = args.RepositoryURL - ctx.Actions().StateDelta[StateRepoRef] = args.Ref - ctx.Actions().StateDelta[StateSubPath] = args.SubPath - ctx.Actions().StateDelta[StateRepoManifest] = string(raw) - ctx.Actions().StateDelta[StateRepoLocalPath] = localPath - - return FetchRepoTreeResult{ - FileCount: len(manifest), - Manifest: manifest, - }, nil - } -} - -type ReadRepoFileArgs struct { - Path string `json:"path"` -} - -type ReadRepoFileResult struct { - Path string `json:"path"` - Content string `json:"content"` -} - -func newReadRepoFileTool() func(tool.Context, ReadRepoFileArgs) (ReadRepoFileResult, error) { - return func(ctx tool.Context, args ReadRepoFileArgs) (ReadRepoFileResult, error) { - v, err := ctx.State().Get(StateRepoLocalPath) - if err != nil { - return ReadRepoFileResult{}, fmt.Errorf("read repo local path from state: %w", err) - } - - localPath, ok := v.(string) - if !ok || localPath == "" { - return ReadRepoFileResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") - } - - content, err := readRepoFileFromCachedCheckout(localPath, args.Path) - if err != nil { - return ReadRepoFileResult{}, err - } - - loaded := map[string]string{} - existing, err := ctx.State().Get(StateLoadedFiles) - if err == nil && existing != nil { - if s, ok := existing.(string); ok && s != "" { - _ = json.Unmarshal([]byte(s), &loaded) - } - } - - loaded[args.Path] = content - raw, _ := json.Marshal(loaded) - ctx.Actions().StateDelta[StateLoadedFiles] = string(raw) - - return ReadRepoFileResult{ - Path: args.Path, - Content: content, - }, nil + if err != nil { + return nil, err } -} - -type WriteOutputFileArgs struct { - Markdown string `json:"markdown"` - OutputPath string `json:"output_path,omitempty"` -} -type WriteOutputFileResult struct { - Path string `json:"path"` + return &Documentor{inner: da}, nil } -func newWriteOutputFileTool() func(tool.Context, WriteOutputFileArgs) (WriteOutputFileResult, error) { - return func(ctx tool.Context, args WriteOutputFileArgs) (WriteOutputFileResult, error) { - out := args.OutputPath - if out == "" { - v, err := ctx.State().Get(StateOutputPath) - if err == nil { - if s, ok := v.(string); ok { - out = s - } - } - } - if out == "" { - return WriteOutputFileResult{}, fmt.Errorf("output path is required") - } - - if err := writeTextFile(out, args.Markdown); err != nil { - return WriteOutputFileResult{}, err - } - - ctx.Actions().StateDelta[StateDocumentation] = args.Markdown - return WriteOutputFileResult{Path: out}, nil - } +// Agent returns the inner agent interface (higher abstraction may not be necessary but we will see) +func (d *Documentor) Agent() agent.Agent { + return d.inner } diff --git a/agents/documentor/prompts.go b/agents/documentor/prompts.go index 0ea3dfb..87d32b8 100644 --- a/agents/documentor/prompts.go +++ b/agents/documentor/prompts.go @@ -1,67 +1,31 @@ package documentor -import ( - "fmt" - "strings" -) - -func buildAnalysisPrompt(state State) string { - var b strings.Builder - - fmt.Fprintf(&b, "Analyze this repository and produce architecture notes.\n\n") - fmt.Fprintf(&b, "Repository: %s\n", state.Repo.URL) - fmt.Fprintf(&b, "Ref: %s\n\n", state.Repo.Ref) - - fmt.Fprintf(&b, "Manifest:\n") - for _, f := range state.Manifest { - fmt.Fprintf(&b, "- %s (lang=%s, pkg=%s, entry=%t)\n", f.Path, f.Language, f.PackageName, f.IsEntry) - } - - fmt.Fprintf(&b, "\nSelected file contents:\n") - for _, f := range state.Selected { - fmt.Fprintf(&b, "\n===== FILE: %s =====\n%s\n", f.Path, f.Content) - } - - fmt.Fprintf(&b, ` -Write architecture notes covering: -- purpose of the repo/subsystem -- main packages/modules -- entry points -- key types/functions/interfaces -- config flow -- error handling patterns -- extension points -- unclear areas / TODOs - -Be precise. Do not invent behavior. -`) - return b.String() -} - -func buildWriterPrompt(state State) string { - var b strings.Builder - - fmt.Fprintf(&b, "Write high-quality maintainer documentation as markdown.\n\n") - fmt.Fprintf(&b, "Repository: %s\n", state.Repo.URL) - fmt.Fprintf(&b, "Ref: %s\n\n", state.Repo.Ref) - - fmt.Fprintf(&b, "Architecture notes:\n%s\n\n", state.AnalysisMD) - - fmt.Fprintf(&b, ` -Output requirements: -- Title -- Executive summary -- Architecture overview -- Package/module breakdown -- Key types and functions -- Execution/control flow -- Configuration and dependencies -- Extension points -- Operational notes / caveats -- File index of the most important files - -Include file paths and symbol names where possible. -Do not include unsupported claims. -`) - return b.String() +func buildInstruction() string { + return ` +You are a code documentation agent. + +Repository: {repo_url} +Ref: {repo_ref?} +Sub-path filter: {sub_path?} +Output path: {output_path} +Max files to read: {max_files?} + +Workflow: +1. Call fetch_repo_tree first using the repository_url, ref, and sub_path from state. +2. Inspect the manifest and identify the most relevant files for architecture and code-level documentation. +3. Prefer entry points, cmd/, internal/, pkg/, config, and core domain files. +4. Skip tests, generated files, vendor, binaries, and irrelevant assets unless they are central. +5. Do not read more than max_files files. +6. Call read_repo_file for each selected file. +7. Write detailed maintainers' documentation in markdown. +8. Call write_output_file with the completed markdown and output_path. + +Requirements: +- Explain architecture and package responsibilities. +- Explain key types, functions, interfaces, and control flow. +- Explain configuration, dependencies, and extension points. +- Mention important file paths and symbol names. +- Do not invent behavior beyond the code retrieved. +- If repository coverage is partial, say so explicitly. +` } diff --git a/agents/documentor/repo.go b/agents/documentor/repo.go index 500fff3..3ab4be6 100644 --- a/agents/documentor/repo.go +++ b/agents/documentor/repo.go @@ -93,7 +93,11 @@ func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, err if err != nil { return "", fmt.Errorf("download repository archive: %w", err) } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Println(err) + } + }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download repository archive failed: %s", resp.Status) @@ -115,7 +119,11 @@ func untarGz(r io.Reader, dest string) error { if err != nil { return err } - defer gzr.Close() + defer func() { + if err := gzr.Close(); err != nil { + fmt.Println(err) + } + }() tr := tar.NewReader(gzr) for { diff --git a/agents/documentor/tools.go b/agents/documentor/tools.go new file mode 100644 index 0000000..e499be2 --- /dev/null +++ b/agents/documentor/tools.go @@ -0,0 +1,128 @@ +package documentor + +import ( + "encoding/json" + "fmt" + + "google.golang.org/adk/tool" +) + +type FetchRepoTreeArgs struct { + RepositoryURL string `json:"repository_url"` + Ref string `json:"ref,omitempty"` + SubPath string `json:"sub_path,omitempty"` +} + +type FileEntry struct { + Path string `json:"path"` + Kind string `json:"kind"` + Size int64 `json:"size,omitempty"` +} + +type FetchRepoTreeResult struct { + FileCount int `json:"file_count"` + Manifest []FileEntry `json:"manifest"` +} + +func newFetchRepoTreeTool(cfg Config) func(tool.Context, FetchRepoTreeArgs) (FetchRepoTreeResult, error) { + return func(ctx tool.Context, args FetchRepoTreeArgs) (FetchRepoTreeResult, error) { + localPath, manifest, err := fetchRepoManifest(args.RepositoryURL, args.Ref, args.SubPath, cfg.WorkDir) + if err != nil { + return FetchRepoTreeResult{}, err + } + + raw, err := json.Marshal(manifest) + if err != nil { + return FetchRepoTreeResult{}, err + } + + ctx.Actions().StateDelta[StateRepoURL] = args.RepositoryURL + ctx.Actions().StateDelta[StateRepoRef] = args.Ref + ctx.Actions().StateDelta[StateSubPath] = args.SubPath + ctx.Actions().StateDelta[StateRepoManifest] = string(raw) + ctx.Actions().StateDelta[StateRepoLocalPath] = localPath + + return FetchRepoTreeResult{ + FileCount: len(manifest), + Manifest: manifest, + }, nil + } +} + +type ReadRepoFileArgs struct { + Path string `json:"path"` +} + +type ReadRepoFileResult struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func newReadRepoFileTool() func(tool.Context, ReadRepoFileArgs) (ReadRepoFileResult, error) { + return func(ctx tool.Context, args ReadRepoFileArgs) (ReadRepoFileResult, error) { + v, err := ctx.State().Get(StateRepoLocalPath) + if err != nil { + return ReadRepoFileResult{}, fmt.Errorf("read repo local path from state: %w", err) + } + + localPath, ok := v.(string) + if !ok || localPath == "" { + return ReadRepoFileResult{}, fmt.Errorf("repository cache not initialized; call fetch_repo_tree first") + } + + content, err := readRepoFileFromCachedCheckout(localPath, args.Path) + if err != nil { + return ReadRepoFileResult{}, err + } + + loaded := map[string]string{} + existing, err := ctx.State().Get(StateLoadedFiles) + if err == nil && existing != nil { + if s, ok := existing.(string); ok && s != "" { + _ = json.Unmarshal([]byte(s), &loaded) + } + } + + loaded[args.Path] = content + raw, _ := json.Marshal(loaded) + ctx.Actions().StateDelta[StateLoadedFiles] = string(raw) + + return ReadRepoFileResult{ + Path: args.Path, + Content: content, + }, nil + } +} + +type WriteOutputFileArgs struct { + Markdown string `json:"markdown"` + OutputPath string `json:"output_path,omitempty"` +} + +type WriteOutputFileResult struct { + Path string `json:"path"` +} + +func newWriteOutputFileTool() func(tool.Context, WriteOutputFileArgs) (WriteOutputFileResult, error) { + return func(ctx tool.Context, args WriteOutputFileArgs) (WriteOutputFileResult, error) { + out := args.OutputPath + if out == "" { + v, err := ctx.State().Get(StateOutputPath) + if err == nil { + if s, ok := v.(string); ok { + out = s + } + } + } + if out == "" { + return WriteOutputFileResult{}, fmt.Errorf("output path is required") + } + + if err := writeTextFile(out, args.Markdown); err != nil { + return WriteOutputFileResult{}, err + } + + ctx.Actions().StateDelta[StateDocumentation] = args.Markdown + return WriteOutputFileResult{Path: out}, nil + } +} diff --git a/agents/documentor/types.go b/agents/documentor/types.go index 17e4a67..ee50793 100644 --- a/agents/documentor/types.go +++ b/agents/documentor/types.go @@ -14,35 +14,3 @@ const ( StateDocumentation = "documentation_markdown" ) - -type FileInfo struct { - Path string - Size int64 - Language string - PackageName string - IsEntry bool - IsTest bool - IsGenerated bool -} - -type SourceFile struct { - Path string - Content string -} - -type RepoMetadata struct { - Owner string - Name string - Ref string - URL string -} - -type State struct { - Repo RepoMetadata - LocalPath string - OutputPath string - Manifest []FileInfo - Selected []SourceFile - AnalysisMD string - Documentation string -} diff --git a/agents/documentor/workflow.go b/agents/documentor/workflow.go deleted file mode 100644 index 9bc562e..0000000 --- a/agents/documentor/workflow.go +++ /dev/null @@ -1,28 +0,0 @@ -package documentor - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" -) - -// ensureClone clones repoURL into workDir if it has not been cloned yet. -func ensureClone(repoURL, ref, workDir string) error { - if _, err := os.Stat(filepath.Join(workDir, ".git")); err == nil { - return nil // already cloned - } - - args := []string{"clone", "--depth=1"} - if ref != "" { - args = append(args, "--branch", ref) - } - args = append(args, repoURL, ".") - - cmd := exec.Command("git", args...) - cmd.Dir = workDir - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) - } - return nil -} diff --git a/cmd/documentor.go b/cmd/documentor.go index 301cbeb..355ea53 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "log/slog" "os" agentpkg "google.golang.org/adk/agent" @@ -11,6 +12,7 @@ import ( "github.com/ATMackay/agent/agents/documentor" "github.com/spf13/cobra" + "github.com/spf13/viper" ) func NewDocumentorCmd() *cobra.Command { @@ -20,14 +22,16 @@ func NewDocumentorCmd() *cobra.Command { var output string var maxFiles int var model string + var apiKey string cmd := &cobra.Command{ Use: "documentor", Short: "Run the code documentation agent", RunE: func(cmd *cobra.Command, args []string) error { - apiKey := os.Getenv("GOOGLE_API_KEY") + // Prefer explicit flag, then env vars via Viper. + apiKey = viper.GetString("google-api-key") if apiKey == "" { - return fmt.Errorf("GOOGLE_API_KEY is required") + return fmt.Errorf("google api key is required; set --google-api-key or export GOOGLE_API_KEY") } if repoURL == "" { return fmt.Errorf("--repo is required") @@ -40,10 +44,22 @@ func NewDocumentorCmd() *cobra.Command { if err != nil { return fmt.Errorf("create work dir: %w", err) } - defer os.RemoveAll(workDir) + defer func() { + if err := os.RemoveAll(workDir); err != nil { + fmt.Println(err) + } + }() ctx := cmd.Context() + slog.Info( + "creating documentor agent", + "dir", workDir, + "model", model, + "output", output, + "repoURL", repoURL, + ) + doc, err := documentor.NewDocumentorAgent(ctx, documentor.Config{ ModelName: model, APIKey: apiKey, @@ -53,10 +69,16 @@ func NewDocumentorCmd() *cobra.Command { return fmt.Errorf("create agent: %w", err) } + slog.Info( + "crrated agent", + "agent_name", doc.Agent().Name(), + "agent_description", doc.Agent().Description(), + ) + sessService := session.InMemoryService() r, err := runner.New(runner.Config{ AppName: "documentor", - Agent: doc, + Agent: doc.Agent(), SessionService: sessService, }) if err != nil { @@ -96,14 +118,15 @@ func NewDocumentorCmd() *cobra.Command { if err != nil { return fmt.Errorf("agent error: %w", err) } - _ = event + // handle event (log) + slog.Info("event", "response_content", event.Content, "branch", event.Branch) } if _, err := os.Stat(output); err != nil { return fmt.Errorf("agent finished but output file was not created: %w", err) } - fmt.Printf("Documentation written to %s\n", output) + slog.Info("Documentation written to", "output_file", output) return nil }, } @@ -115,5 +138,16 @@ func NewDocumentorCmd() *cobra.Command { cmd.Flags().IntVar(&maxFiles, "max-files", 20, "Maximum number of files to read") cmd.Flags().StringVar(&model, "model", "gemini-2.5-pro", "Gemini model to use") + // Bind flags to environment variables + must(viper.BindPFlag("repo", cmd.Flags().Lookup("repo"))) + must(viper.BindPFlag("ref", cmd.Flags().Lookup("ref"))) + must(viper.BindPFlag("path", cmd.Flags().Lookup("path"))) + must(viper.BindPFlag("output", cmd.Flags().Lookup("output"))) + must(viper.BindPFlag("max-files", cmd.Flags().Lookup("max-files"))) + must(viper.BindPFlag("model", cmd.Flags().Lookup("model"))) + + // GOOGLE_API_KEY is preferred, GEMINI_API_KEY is accepted as fallback. + must(viper.BindEnv("google-api-key", "GOOGLE_API_KEY", "GEMINI_API_KEY")) + return cmd } diff --git a/cmd/run.go b/cmd/run.go index 78d207e..98c24a0 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -48,11 +48,6 @@ func NewRunCmd() *cobra.Command { cmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error, fatal, panic)") cmd.Flags().String("log-format", "text", "Log format (text, json)") - must := func(err error) { - if err != nil { - panic(err) - } - } // Bind flags to environment variables must(viper.BindPFlag("log-level", cmd.Flags().Lookup("log-level"))) must(viper.BindPFlag("log-format", cmd.Flags().Lookup("log-format"))) @@ -64,3 +59,9 @@ func NewRunCmd() *cobra.Command { return cmd } + +func must(err error) { + if err != nil { + panic(err) + } +} From 4ece21176fec9c4165be91c93eacfd9bf42c3548 Mon Sep 17 00:00:00 2001 From: ATMackay Date: Sat, 14 Mar 2026 19:00:38 +1100 Subject: [PATCH 3/7] refactor tools and documentor wiring --- agents/documentor/config.go | 18 ++++--- agents/documentor/documentor.go | 60 +++++---------------- agents/documentor/{prompts.go => prompt.go} | 0 agents/documentor/repo.go | 5 +- agents/documentor/{types.go => state.go} | 0 agents/documentor/tools.go | 48 ++++++++++++++++- cmd/documentor.go | 34 ++++++++---- 7 files changed, 98 insertions(+), 67 deletions(-) rename agents/documentor/{prompts.go => prompt.go} (100%) rename agents/documentor/{types.go => state.go} (100%) diff --git a/agents/documentor/config.go b/agents/documentor/config.go index 315007f..856b5cf 100644 --- a/agents/documentor/config.go +++ b/agents/documentor/config.go @@ -4,15 +4,21 @@ import "errors" // Config is the base config struct for documentation agent type Config struct { - ModelName string - APIKey string - WorkDir string + WorkDir string +} + +func (c *Config) SetDefaults() *Config { + if c.WorkDir == "" { + c.WorkDir = "." + } + return c } func (c Config) Validate() error { - // model name & work dir use defaults - if c.APIKey == "" { - return errors.New("missing API key") + // ensure workdir is either explicitly set or defaults are set + // Empty workdir not allowed + if c.WorkDir == "" { + return errors.New("empty work dir supplied") } return nil } diff --git a/agents/documentor/documentor.go b/agents/documentor/documentor.go index 974bc80..930decf 100644 --- a/agents/documentor/documentor.go +++ b/agents/documentor/documentor.go @@ -2,14 +2,11 @@ package documentor import ( "context" - "fmt" "google.golang.org/adk/agent" "google.golang.org/adk/agent/llmagent" - "google.golang.org/adk/model/gemini" + "google.golang.org/adk/model" "google.golang.org/adk/tool" - "google.golang.org/adk/tool/functiontool" - "google.golang.org/genai" ) type Documentor struct { @@ -17,55 +14,24 @@ type Documentor struct { } // NewDocumentorAgent returns a Documentor. -func NewDocumentorAgent(ctx context.Context, cfg Config) (*Documentor, error) { - if cfg.ModelName == "" { - cfg.ModelName = "gemini-2.5-pro" - } - if cfg.APIKey == "" { - return nil, fmt.Errorf("API key is required") - } - - model, err := gemini.NewModel(ctx, cfg.ModelName, &genai.ClientConfig{ - APIKey: cfg.APIKey, - }) +func NewDocumentorAgent(ctx context.Context, cfg *Config, model model.LLM) (*Documentor, error) { + // Configure documentor agent tools + fetchRepoTreeTool, err := NewFetchRepoTreeTool(cfg) if err != nil { - return nil, fmt.Errorf("create model: %w", err) - } - - fetchRepoTreeTool, err := functiontool.New( - functiontool.Config{ - Name: "fetch_repo_tree", - Description: "Download the GitHub repository to a local cache, build a source-file manifest, and store both in state.", - }, - newFetchRepoTreeTool(cfg), - ) - if err != nil { - return nil, fmt.Errorf("create fetch_repo_tree tool: %w", err) + return nil, err } - readRepoFileTool, err := functiontool.New( - functiontool.Config{ - Name: "read_repo_file", - Description: "Read a repository file from the cached checkout and store it in state.", - }, - newReadRepoFileTool(), - ) + readRepoFileTool, err := NewReadRepoFileTool(cfg) if err != nil { - return nil, fmt.Errorf("create read_repo_file tool: %w", err) + return nil, err } - writeOutputTool, err := functiontool.New( - functiontool.Config{ - Name: "write_output_file", - Description: "Write markdown documentation to the requested output file.", - }, - newWriteOutputFileTool(), - ) + writeOutputTool, err := NewWriteOutputTool(cfg) if err != nil { - return nil, fmt.Errorf("create write_output_file tool: %w", err) + return nil, err } - // Instantiate LLM agent + // Instantiate Documentor LLM agent da, err := llmagent.New(llmagent.Config{ Name: "documentor", Model: model, @@ -73,8 +39,8 @@ func NewDocumentorAgent(ctx context.Context, cfg Config) (*Documentor, error) { Instruction: buildInstruction(), Tools: []tool.Tool{ fetchRepoTreeTool, // Fetch Git Repository files - readRepoFileTool, - writeOutputTool, + readRepoFileTool, // Read files tool + writeOutputTool, // Write output to file tool }, OutputKey: StateDocumentation, }) @@ -85,7 +51,7 @@ func NewDocumentorAgent(ctx context.Context, cfg Config) (*Documentor, error) { return &Documentor{inner: da}, nil } -// Agent returns the inner agent interface (higher abstraction may not be necessary but we will see) +// Agent returns the inner agent interface (higher abstraction may not be necessary but we will see). func (d *Documentor) Agent() agent.Agent { return d.inner } diff --git a/agents/documentor/prompts.go b/agents/documentor/prompt.go similarity index 100% rename from agents/documentor/prompts.go rename to agents/documentor/prompt.go diff --git a/agents/documentor/repo.go b/agents/documentor/repo.go index 3ab4be6..20062fe 100644 --- a/agents/documentor/repo.go +++ b/agents/documentor/repo.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "fmt" "io" + "log/slog" "net/http" "net/url" "os" @@ -95,7 +96,7 @@ func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, err } defer func() { if err := resp.Body.Close(); err != nil { - fmt.Println(err) + slog.Error("error closing body", "err", err) } }() @@ -121,7 +122,7 @@ func untarGz(r io.Reader, dest string) error { } defer func() { if err := gzr.Close(); err != nil { - fmt.Println(err) + slog.Error("error closing body", "err", err) } }() diff --git a/agents/documentor/types.go b/agents/documentor/state.go similarity index 100% rename from agents/documentor/types.go rename to agents/documentor/state.go diff --git a/agents/documentor/tools.go b/agents/documentor/tools.go index e499be2..75f42de 100644 --- a/agents/documentor/tools.go +++ b/agents/documentor/tools.go @@ -5,6 +5,7 @@ import ( "fmt" "google.golang.org/adk/tool" + "google.golang.org/adk/tool/functiontool" ) type FetchRepoTreeArgs struct { @@ -24,7 +25,7 @@ type FetchRepoTreeResult struct { Manifest []FileEntry `json:"manifest"` } -func newFetchRepoTreeTool(cfg Config) func(tool.Context, FetchRepoTreeArgs) (FetchRepoTreeResult, error) { +func newFetchRepoTreeTool(cfg *Config) func(tool.Context, FetchRepoTreeArgs) (FetchRepoTreeResult, error) { return func(ctx tool.Context, args FetchRepoTreeArgs) (FetchRepoTreeResult, error) { localPath, manifest, err := fetchRepoManifest(args.RepositoryURL, args.Ref, args.SubPath, cfg.WorkDir) if err != nil { @@ -49,6 +50,21 @@ func newFetchRepoTreeTool(cfg Config) func(tool.Context, FetchRepoTreeArgs) (Fet } } +// NewFetchRepoTool returns a fetch_repo_tree function tool. +func NewFetchRepoTreeTool(cfg *Config) (tool.Tool, error) { + fetchRepoTreeTool, err := functiontool.New( + functiontool.Config{ + Name: "fetch_repo_tree", + Description: "Download the GitHub repository to a local cache, build a source-file manifest, and store both in state.", + }, + newFetchRepoTreeTool(cfg), + ) + if err != nil { + return nil, fmt.Errorf("create fetch_repo_tree tool: %w", err) + } + return fetchRepoTreeTool, nil +} + type ReadRepoFileArgs struct { Path string `json:"path"` } @@ -94,6 +110,21 @@ func newReadRepoFileTool() func(tool.Context, ReadRepoFileArgs) (ReadRepoFileRes } } +// NewFetchRepoTool returns a fetch_repo_tree function tool. +func NewReadRepoFileTool(_ *Config) (tool.Tool, error) { + readRepoFileTool, err := functiontool.New( + functiontool.Config{ + Name: "read_repo_file", + Description: "Read a repository file from the cached checkout and store it in state.", + }, + newReadRepoFileTool(), + ) + if err != nil { + return nil, fmt.Errorf("create read_repo_file tool: %w", err) + } + return readRepoFileTool, nil +} + type WriteOutputFileArgs struct { Markdown string `json:"markdown"` OutputPath string `json:"output_path,omitempty"` @@ -126,3 +157,18 @@ func newWriteOutputFileTool() func(tool.Context, WriteOutputFileArgs) (WriteOutp return WriteOutputFileResult{Path: out}, nil } } + +// NewWriteOutputTool returns a write_output_file function tool. +func NewWriteOutputTool(_ *Config) (tool.Tool, error) { + writeOutputTool, err := functiontool.New( + functiontool.Config{ + Name: "write_output_file", + Description: "Write markdown documentation to the requested output file.", + }, + newWriteOutputFileTool(), + ) + if err != nil { + return nil, fmt.Errorf("create write_output_file tool: %w", err) + } + return writeOutputTool, nil +} diff --git a/cmd/documentor.go b/cmd/documentor.go index 355ea53..cb5520f 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -6,6 +6,7 @@ import ( "os" agentpkg "google.golang.org/adk/agent" + "google.golang.org/adk/model/gemini" "google.golang.org/adk/runner" "google.golang.org/adk/session" "google.golang.org/genai" @@ -21,7 +22,7 @@ func NewDocumentorCmd() *cobra.Command { var pathPrefix string var output string var maxFiles int - var model string + var modelName string var apiKey string cmd := &cobra.Command{ @@ -46,39 +47,50 @@ func NewDocumentorCmd() *cobra.Command { } defer func() { if err := os.RemoveAll(workDir); err != nil { - fmt.Println(err) + slog.Error("error removing body", "err", err) } }() + cfg := &documentor.Config{WorkDir: workDir} + cfg = cfg.SetDefaults() + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + ctx := cmd.Context() slog.Info( "creating documentor agent", "dir", workDir, - "model", model, + "model", modelName, "output", output, "repoURL", repoURL, ) - doc, err := documentor.NewDocumentorAgent(ctx, documentor.Config{ - ModelName: model, - APIKey: apiKey, - WorkDir: workDir, + // Start with Gemini models + // TODO create abstraction and package to support arbitrary model types. + mod, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{ + APIKey: apiKey, }) + if err != nil { + return fmt.Errorf("create model: %w", err) + } + + docAgent, err := documentor.NewDocumentorAgent(ctx, cfg, mod) if err != nil { return fmt.Errorf("create agent: %w", err) } slog.Info( "crrated agent", - "agent_name", doc.Agent().Name(), - "agent_description", doc.Agent().Description(), + "agent_name", docAgent.Agent().Name(), + "agent_description", docAgent.Agent().Description(), ) sessService := session.InMemoryService() r, err := runner.New(runner.Config{ AppName: "documentor", - Agent: doc.Agent(), + Agent: docAgent.Agent(), SessionService: sessService, }) if err != nil { @@ -136,7 +148,7 @@ func NewDocumentorCmd() *cobra.Command { cmd.Flags().StringVar(&pathPrefix, "path", "", "Optional subdirectory to document") cmd.Flags().StringVar(&output, "output", "", "Output file path for the generated markdown") cmd.Flags().IntVar(&maxFiles, "max-files", 20, "Maximum number of files to read") - cmd.Flags().StringVar(&model, "model", "gemini-2.5-pro", "Gemini model to use") + cmd.Flags().StringVar(&modelName, "model", "gemini-2.5-pro", "Gemini model to use") // Bind flags to environment variables must(viper.BindPFlag("repo", cmd.Flags().Lookup("repo"))) From 7afcb8cd39364f0539e2aa0786416e10871c7710 Mon Sep 17 00:00:00 2001 From: ATMackay Date: Tue, 17 Mar 2026 00:45:49 +1100 Subject: [PATCH 4/7] update Makefule --- Makefile | 14 +++++++------- README.md | 24 +++++++++++++++++++++--- agents/documentor/documentor.go | 4 ++-- agents/documentor/files.go | 15 --------------- agents/documentor/tools.go | 10 ++++++++++ cmd/cmd.go | 4 ++-- cmd/documentor.go | 4 ++-- constants/constants.go | 2 +- main.go | 11 +++-------- 9 files changed, 48 insertions(+), 40 deletions(-) delete mode 100644 agents/documentor/files.go diff --git a/Makefile b/Makefile index 62ccc7b..a3c30d7 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ # Alex Mackay 2026 -# Build folder +# Build folder (CLI) BUILD_FOLDER = build COVERAGE_BUILD_FOLDER ?= $(BUILD_FOLDER)/coverage UNIT_COVERAGE_OUT ?= $(COVERAGE_BUILD_FOLDER)/ut_cov.out -BIN ?= $(BUILD_FOLDER)/agent +BIN ?= $(BUILD_FOLDER)/agent-cli # Packages -PKG ?= github.com/ATMackay/agent +PKG ?= github.com/ATMackay/agent-cli CONSTANTS_PKG ?= $(PKG)/constants @@ -31,10 +31,10 @@ install: build mv $(BIN) $(GOBIN) run: build - @./$(BUILD_FOLDER)/agents run documentor --repo https://github.com/ATMackay/agent.git + @./$(BUILD_FOLDER)/agent-cli run documentor --repo https://github.com/ATMackay/agent.git -build/coverage: +test: @mkdir -p $(COVERAGE_BUILD_FOLDER) + @go test -cover -coverprofile $(UNIT_COVERAGE_OUT) -v ./... -test: build/coverage - @go test -cover -coverprofile $(UNIT_COVERAGE_OUT) -v ./... \ No newline at end of file +.PHONY: build install run test \ No newline at end of file diff --git a/README.md b/README.md index ff284da..5f74ec5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ -# AI Agents with Google ADK +# Agent CLI - AI Agents with Google ADK -This is a toy project to build AI agents using a pure Go stack. +This is a toy project to build AI agents using a pure Go stack. Exploring the capabilities of Google's [ADK](https://google.github.io/adk-docs/get-started/go/). -The aim is to create multiple agents that can be launched from the same CLI \ No newline at end of file +## Getting started + +Run the documentation agent on this project + + +Export API key (Gemini, Claude) +``` +export API_KEY=AI...Zs +``` + +Build agent CLI +``` +make build +``` + +Run the agent () +``` +./build/agent-cli run documentor --repo https://github.com/ATMackay/agent.git --output doc.md -- model sonnet_4_5 +``` \ No newline at end of file diff --git a/agents/documentor/documentor.go b/agents/documentor/documentor.go index 930decf..657766b 100644 --- a/agents/documentor/documentor.go +++ b/agents/documentor/documentor.go @@ -13,8 +13,8 @@ type Documentor struct { inner agent.Agent } -// NewDocumentorAgent returns a Documentor. -func NewDocumentorAgent(ctx context.Context, cfg *Config, model model.LLM) (*Documentor, error) { +// NewDocumentor returns a Documentor agent. +func NewDocumentor(ctx context.Context, cfg *Config, model model.LLM) (*Documentor, error) { // Configure documentor agent tools fetchRepoTreeTool, err := NewFetchRepoTreeTool(cfg) if err != nil { diff --git a/agents/documentor/files.go b/agents/documentor/files.go deleted file mode 100644 index fb66dbf..0000000 --- a/agents/documentor/files.go +++ /dev/null @@ -1,15 +0,0 @@ -package documentor - -import ( - "fmt" - "os" - "path/filepath" -) - -// writeTextFile creates parent directories as needed and writes content to path. -func writeTextFile(path, content string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) - } - return os.WriteFile(path, []byte(content), 0o644) -} diff --git a/agents/documentor/tools.go b/agents/documentor/tools.go index 75f42de..6d84869 100644 --- a/agents/documentor/tools.go +++ b/agents/documentor/tools.go @@ -3,6 +3,8 @@ package documentor import ( "encoding/json" "fmt" + "os" + "path/filepath" "google.golang.org/adk/tool" "google.golang.org/adk/tool/functiontool" @@ -158,6 +160,14 @@ func newWriteOutputFileTool() func(tool.Context, WriteOutputFileArgs) (WriteOutp } } +// writeTextFile creates parent directories as needed and writes content to path. +func writeTextFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + return os.WriteFile(path, []byte(content), 0o644) +} + // NewWriteOutputTool returns a write_output_file function tool. func NewWriteOutputTool(_ *Config) (tool.Tool, error) { writeOutputTool, err := functiontool.New( diff --git a/cmd/cmd.go b/cmd/cmd.go index a8bb746..adec6ac 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -9,10 +9,10 @@ import ( const EnvPrefix = "AGENT" -func NewAgentCmd() *cobra.Command { +func NewAgentCLICmd() *cobra.Command { cmd := &cobra.Command{ Use: "agent [subcommand]", - Short: fmt.Sprintf("agent server command line interface.\n\nVERSION:\n semver: %s\n commit: %s\n compilation date: %s", + Short: fmt.Sprintf("agent command line interface.\n\nVERSION:\n semver: %s\n commit: %s\n compilation date: %s", constants.Version, constants.GitCommit, constants.BuildDate), RunE: runHelp, } diff --git a/cmd/documentor.go b/cmd/documentor.go index cb5520f..63e2198 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -76,13 +76,13 @@ func NewDocumentorCmd() *cobra.Command { return fmt.Errorf("create model: %w", err) } - docAgent, err := documentor.NewDocumentorAgent(ctx, cfg, mod) + docAgent, err := documentor.NewDocumentor(ctx, cfg, mod) if err != nil { return fmt.Errorf("create agent: %w", err) } slog.Info( - "crrated agent", + "created agent", "agent_name", docAgent.Agent().Name(), "agent_description", docAgent.Agent().Description(), ) diff --git a/constants/constants.go b/constants/constants.go index d713804..a83fe31 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -1,3 +1,3 @@ package constants -const ServiceName = "agent server" +const ServiceName = "agent-cli" diff --git a/main.go b/main.go index cfc4d8a..c7aa5f6 100644 --- a/main.go +++ b/main.go @@ -19,18 +19,13 @@ import ( // Static Code analysis agent // Documentation agent -// @title Agent API +// @title Agent CLI // @version 0.1.0 -// @description API for running code analysis agents +// @description CLI for AI code/document analysis agents // @schemes TODO // @host TODO - -// @securityDefinitions.apikey XAuthPassword -// @in header -// @name X-Auth-Password - func main() { - command := cmd.NewAgentCmd() + command := cmd.NewAgentCLICmd() if err := command.Execute(); err != nil { slog.Error("main: execution failed", "error", err) os.Exit(1) From aa4346e2fcfff711be54bd914aecd41546fe83ff Mon Sep 17 00:00:00 2001 From: ATMackay Date: Tue, 17 Mar 2026 13:25:33 +1100 Subject: [PATCH 5/7] support claude models --- Makefile | 10 +++++++++- cmd/documentor.go | 51 ++++++++++++++++++++++++++++------------------- cmd/run.go | 12 +++++------ go.mod | 26 ++++++++++++++---------- go.sum | 51 ++++++++++++++++++++++++++++++----------------- main.go | 12 ----------- model/claude.go | 21 +++++++++++++++++++ model/gemini.go | 23 +++++++++++++++++++++ model/model.go | 40 +++++++++++++++++++++++++++++++++++++ 9 files changed, 178 insertions(+), 68 deletions(-) create mode 100644 model/claude.go create mode 100644 model/gemini.go create mode 100644 model/model.go diff --git a/Makefile b/Makefile index a3c30d7..baf8efb 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ UNIT_COVERAGE_OUT ?= $(COVERAGE_BUILD_FOLDER)/ut_cov.out BIN ?= $(BUILD_FOLDER)/agent-cli # Packages -PKG ?= github.com/ATMackay/agent-cli +PKG ?= github.com/ATMackay/agent CONSTANTS_PKG ?= $(PKG)/constants @@ -21,6 +21,14 @@ ifndef DIRTY DIRTY := $(shell if [ -n "$$(git status --porcelain 2>/dev/null)" ]; then echo true; else echo false; fi) endif +LDFLAGS := -s -w \ + -X '$(CONSTANTS_PKG).Version=$(VERSION_TAG)' \ + -X '$(CONSTANTS_PKG).CommitDate=$(COMMIT_DATE)' \ + -X '$(CONSTANTS_PKG).GitCommit=$(GIT_COMMIT)' \ + -X '$(CONSTANTS_PKG).BuildDate=$(BUILD_DATE)' \ + -X '$(CONSTANTS_PKG).Dirty=$(DIRTY)' + + build: @mkdir -p build @echo ">> building $(BIN) (version=$(VERSION_TAG) commit=$(GIT_COMMIT) dirty=$(DIRTY))" diff --git a/cmd/documentor.go b/cmd/documentor.go index 63e2198..046e815 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -5,24 +5,25 @@ import ( "log/slog" "os" + "github.com/ATMackay/agent/agents/documentor" + "github.com/ATMackay/agent/model" + "github.com/spf13/cobra" + "github.com/spf13/viper" agentpkg "google.golang.org/adk/agent" - "google.golang.org/adk/model/gemini" "google.golang.org/adk/runner" "google.golang.org/adk/session" "google.golang.org/genai" - - "github.com/ATMackay/agent/agents/documentor" - "github.com/spf13/cobra" - "github.com/spf13/viper" ) +const userCLI = "cli-user" + func NewDocumentorCmd() *cobra.Command { var repoURL string var ref string var pathPrefix string var output string var maxFiles int - var modelName string + var modelName, modelProvider string var apiKey string cmd := &cobra.Command{ @@ -30,9 +31,9 @@ func NewDocumentorCmd() *cobra.Command { Short: "Run the code documentation agent", RunE: func(cmd *cobra.Command, args []string) error { // Prefer explicit flag, then env vars via Viper. - apiKey = viper.GetString("google-api-key") + apiKey = viper.GetString("api-key") if apiKey == "" { - return fmt.Errorf("google api key is required; set --google-api-key or export GOOGLE_API_KEY") + return fmt.Errorf("google api key is required; set --api-key or export API_KEY") } if repoURL == "" { return fmt.Errorf("--repo is required") @@ -63,15 +64,17 @@ func NewDocumentorCmd() *cobra.Command { "creating documentor agent", "dir", workDir, "model", modelName, + "provider", modelProvider, "output", output, "repoURL", repoURL, ) - // Start with Gemini models - // TODO create abstraction and package to support arbitrary model types. - mod, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{ - APIKey: apiKey, - }) + // Select model provider. Supported providers: 'claude' or gemini. + modelCfg := &model.Config{ + Provider: model.Provider(modelProvider), + Model: modelName, + } + mod, err := model.New(ctx, modelCfg.WithAPIKey(apiKey)) if err != nil { return fmt.Errorf("create model: %w", err) } @@ -109,7 +112,7 @@ func NewDocumentorCmd() *cobra.Command { resp, err := sessService.Create(ctx, &session.CreateRequest{ AppName: "documentor", - UserID: "cli-user", + UserID: userCLI, State: initState, }) if err != nil { @@ -126,12 +129,18 @@ func NewDocumentorCmd() *cobra.Command { }, } - for event, err := range r.Run(ctx, "cli-user", resp.Session.ID(), userMsg, agentpkg.RunConfig{}) { + for event, err := range r.Run(ctx, userCLI, resp.Session.ID(), userMsg, agentpkg.RunConfig{}) { if err != nil { return fmt.Errorf("agent error: %w", err) } // handle event (log) - slog.Info("event", "response_content", event.Content, "branch", event.Branch) + slog.Info("event", "branch", event.Branch) + // Log function calls TODO + // if event.Content != nil && len(event.Content.Parts) != 0 { + // for _, fc := range event.Content.Parts { + // slog.Info("function call", "function", fc.FunctionCall.Name, "input", fc.Text) + // } + // } } if _, err := os.Stat(output); err != nil { @@ -147,8 +156,9 @@ func NewDocumentorCmd() *cobra.Command { cmd.Flags().StringVar(&ref, "ref", "", "Optional branch, tag, or commit") cmd.Flags().StringVar(&pathPrefix, "path", "", "Optional subdirectory to document") cmd.Flags().StringVar(&output, "output", "", "Output file path for the generated markdown") - cmd.Flags().IntVar(&maxFiles, "max-files", 20, "Maximum number of files to read") - cmd.Flags().StringVar(&modelName, "model", "gemini-2.5-pro", "Gemini model to use") + cmd.Flags().IntVar(&maxFiles, "max-files", 50, "Maximum number of files to read") + cmd.Flags().StringVar(&modelName, "model", "", "LLM to use") + cmd.Flags().StringVar(&modelProvider, "provider", "claude", "LLM provider to use (claude or gemini)") // Bind flags to environment variables must(viper.BindPFlag("repo", cmd.Flags().Lookup("repo"))) @@ -157,9 +167,10 @@ func NewDocumentorCmd() *cobra.Command { must(viper.BindPFlag("output", cmd.Flags().Lookup("output"))) must(viper.BindPFlag("max-files", cmd.Flags().Lookup("max-files"))) must(viper.BindPFlag("model", cmd.Flags().Lookup("model"))) + must(viper.BindPFlag("provider", cmd.Flags().Lookup("provider"))) - // GOOGLE_API_KEY is preferred, GEMINI_API_KEY is accepted as fallback. - must(viper.BindEnv("google-api-key", "GOOGLE_API_KEY", "GEMINI_API_KEY")) + // API_KEY is preferred, GOOGLE_API_KEY, GEMINI_API_KEY, CLAUDE_API_KEY are accepted as fallback. + must(viper.BindEnv("api-key", "API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "CLAUDE_API_KEY")) return cmd } diff --git a/cmd/run.go b/cmd/run.go index 98c24a0..4b308ed 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -14,20 +14,18 @@ func NewRunCmd() *cobra.Command { cmd := &cobra.Command{ Use: "run", Short: fmt.Sprintf("Start the %s", constants.ServiceName), - RunE: func(cmd *cobra.Command, args []string) error { - // Read configuration from Viper + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { logLevel := viper.GetString("log-level") logFormat := viper.GetString("log-format") - // - // Execute the main application lifecycle - // - // Initialize logger + if err := initLogging(logLevel, logFormat); err != nil { return fmt.Errorf("failed to initialize logger: %w", err) } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { if isBuildDirty() { - // Warn if the build contains uncommitted changes slog.Warn("running a DIRTY build (uncommitted changes present) — do not run in production") } slog.Info(fmt.Sprintf("starting %s", constants.ServiceName), diff --git a/go.mod b/go.mod index 0809282..65b2350 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,10 @@ module github.com/ATMackay/agent -go 1.26.0 +go 1.25.7 require ( + github.com/anthropics/anthropic-sdk-go v1.23.0 + github.com/louislef299/claude-go-adk v0.0.0-20260217224925-68eb91ba1ac6 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 google.golang.org/adk v0.6.0 @@ -11,7 +13,7 @@ require ( require ( cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.17.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -24,8 +26,8 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/safehtml v0.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -35,19 +37,23 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/log v0.16.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect rsc.io/omap v1.2.0 // indirect rsc.io/ordered v1.1.1 // indirect diff --git a/go.sum b/go.sum index 0ef364a..764cfaf 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= -cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/anthropics/anthropic-sdk-go v1.23.0 h1:YVNnxfVVPJM+zvQ1oDgTJUBtLttGpBHe1WtJBr0QeAs= +github.com/anthropics/anthropic-sdk-go v1.23.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -34,10 +36,10 @@ github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8 github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -46,6 +48,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/louislef299/claude-go-adk v0.0.0-20260217224925-68eb91ba1ac6 h1:dHFmzoRwSaO/txKCkLuvNZtzSRtszf8wfgn7+jEG1Kc= +github.com/louislef299/claude-go-adk v0.0.0-20260217224925-68eb91ba1ac6/go.mod h1:njKCtHWS75UUw/aBgXybHw3Fz9fwAPLHLwLlmQitdRc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -72,10 +76,21 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= @@ -92,17 +107,17 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= @@ -110,10 +125,10 @@ google.golang.org/adk v0.6.0 h1:hQl+K1qcvJ+B6rGBI+9T/Y6t21XsBQ8pRJqZYaOwK5M= google.golang.org/adk v0.6.0/go.mod h1:nSTAyo0DQnua9dfuiDpMWq2crE9jE24ZaFJO4hwueUI= google.golang.org/genai v1.50.0 h1:yHKV/vjoeN9PJ3iF0ur4cBZco4N3Kl7j09rMq7XSoWk= google.golang.org/genai v1.50.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index c7aa5f6..636538b 100644 --- a/main.go +++ b/main.go @@ -7,18 +7,6 @@ import ( "github.com/ATMackay/agent/cmd" ) -// TODO -// TODO -// This is a toy project.... Building AI agents with Google's ADK performing various tasks -// -// Features -// -// Cobra cli framework -// Google ADK for AI agent development -// Agents include... -// Static Code analysis agent -// Documentation agent - // @title Agent CLI // @version 0.1.0 // @description CLI for AI code/document analysis agents diff --git a/model/claude.go b/model/claude.go new file mode 100644 index 0000000..43c6001 --- /dev/null +++ b/model/claude.go @@ -0,0 +1,21 @@ +package model + +import ( + "context" + "fmt" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + anthropicadk "github.com/louislef299/claude-go-adk" + "google.golang.org/adk/model" +) + +func newClaude(ctx context.Context, cfg *Config) (model.LLM, error) { + if cfg.apiKey == "" { + return nil, fmt.Errorf("anthropic api key is required for claude") + } + if cfg.Model == "" { + cfg.Model = string(anthropic.ModelClaudeSonnet4_20250514) + } + return anthropicadk.NewModel(cfg.Model, anthropicadk.AnthropicOption(option.WithAPIKey(cfg.apiKey))), nil +} diff --git a/model/gemini.go b/model/gemini.go new file mode 100644 index 0000000..7c7d41d --- /dev/null +++ b/model/gemini.go @@ -0,0 +1,23 @@ +package model + +import ( + "context" + "fmt" + + "google.golang.org/adk/model" + adkgemini "google.golang.org/adk/model/gemini" + "google.golang.org/genai" +) + +func newGemini(ctx context.Context, cfg *Config) (model.LLM, error) { + if cfg.apiKey == "" { + return nil, fmt.Errorf("google api key is required for gemini") + } + if cfg.Model == "" { + cfg.Model = "gemini-2.5-pro" + } + + return adkgemini.NewModel(ctx, cfg.Model, &genai.ClientConfig{ + APIKey: cfg.apiKey, + }) +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..15a91f6 --- /dev/null +++ b/model/model.go @@ -0,0 +1,40 @@ +package model + +import ( + "context" + "fmt" + + "google.golang.org/adk/model" +) + +type Provider string + +const ( + ProviderGemini Provider = "gemini" + ProviderClaude Provider = "claude" + // TODO support more +) + +// Config is the provider model config with API access key. +type Config struct { + Provider Provider + Model string + + apiKey string +} + +func (c *Config) WithAPIKey(apiKey string) *Config { + c.apiKey = apiKey + return c +} + +func New(ctx context.Context, cfg *Config) (model.LLM, error) { + switch cfg.Provider { + case "", ProviderClaude: // Set Claude as default provider when supplied value is empty. + return newClaude(ctx, cfg) + case ProviderGemini: + return newGemini(ctx, cfg) + default: + return nil, fmt.Errorf("unsupported model provider: %s", cfg.Provider) + } +} From f670572dab9589e52519fd07fe02fd61fed1a1fc Mon Sep 17 00:00:00 2001 From: ATMackay Date: Tue, 17 Mar 2026 18:24:05 +1100 Subject: [PATCH 6/7] working local --- README.md | 4 +- agents/documentor/config.go | 7 - agents/documentor/repo.go | 322 ++++++++++++++++++++++++++++-------- agents/documentor/tools.go | 9 + cmd/documentor.go | 16 +- 5 files changed, 275 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 5f74ec5..6e2a186 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Agent CLI - AI Agents with Google ADK -This is a toy project to build AI agents using a pure Go stack. Exploring the capabilities of Google's [ADK](https://google.github.io/adk-docs/get-started/go/). +This is a toy project to build AI agents using a pure Go stack and explore the capabilities of Google's [ADK](https://google.github.io/adk-docs/get-started/go/). ## Getting started @@ -19,5 +19,5 @@ make build Run the agent () ``` -./build/agent-cli run documentor --repo https://github.com/ATMackay/agent.git --output doc.md -- model sonnet_4_5 +./build/agent-cli run documentor --repo https://github.com/ATMackay/agent.git --output agentcli-doc.md ``` \ No newline at end of file diff --git a/agents/documentor/config.go b/agents/documentor/config.go index 856b5cf..5c52d39 100644 --- a/agents/documentor/config.go +++ b/agents/documentor/config.go @@ -7,13 +7,6 @@ type Config struct { WorkDir string } -func (c *Config) SetDefaults() *Config { - if c.WorkDir == "" { - c.WorkDir = "." - } - return c -} - func (c Config) Validate() error { // ensure workdir is either explicitly set or defaults are set // Empty workdir not allowed diff --git a/agents/documentor/repo.go b/agents/documentor/repo.go index 20062fe..928f893 100644 --- a/agents/documentor/repo.go +++ b/agents/documentor/repo.go @@ -3,39 +3,39 @@ package documentor import ( "archive/tar" "compress/gzip" + "errors" "fmt" "io" - "log/slog" + "io/fs" "net/http" "net/url" "os" + "os/exec" "path/filepath" + "sort" "strings" "time" ) -const maxReadBytes = 128 * 1024 +const ( + maxReadBytes = 128 * 1024 + maxManifestBytes = 512 * 1024 + httpTimeout = 90 * time.Second +) func fetchRepoManifest(repoURL, ref, subPath, workDir string) (string, []FileEntry, error) { - owner, repo, err := parseGitHubRepoURL(repoURL) - if err != nil { - return "", nil, err + if strings.TrimSpace(repoURL) == "" { + return "", nil, fmt.Errorf("repository URL is required") } - root, err := downloadAndExtractGitHubRepo(owner, repo, ref, workDir) + root, err := fetchRepository(repoURL, ref, workDir) if err != nil { return "", nil, err } - if subPath != "" { - root = filepath.Join(root, filepath.Clean(subPath)) - info, err := os.Stat(root) - if err != nil { - return "", nil, fmt.Errorf("sub_path not found: %w", err) - } - if !info.IsDir() { - return "", nil, fmt.Errorf("sub_path is not a directory: %s", subPath) - } + root, err = resolveSubPath(root, subPath) + if err != nil { + return "", nil, err } manifest, err := buildManifest(root) @@ -46,36 +46,50 @@ func fetchRepoManifest(repoURL, ref, subPath, workDir string) (string, []FileEnt return root, manifest, nil } -func parseGitHubRepoURL(repoURL string) (string, string, error) { +func fetchRepository(repoURL, ref, workDir string) (string, error) { + if workDir == "" { + workDir = os.TempDir() + } + + // Prefer fast HTTPS archive fetch for public GitHub repos. + if owner, repo, ok := tryParseGitHubRepoURL(repoURL); ok { + root, err := downloadAndExtractGitHubRepo(owner, repo, ref, workDir) + if err == nil { + return root, nil + } + // Fall through to git CLI fallback. + } + + return cloneRepoWithGit(repoURL, ref, workDir) +} + +func tryParseGitHubRepoURL(repoURL string) (owner, repo string, ok bool) { u, err := url.Parse(repoURL) if err != nil { - return "", "", fmt.Errorf("invalid repository URL: %w", err) + return "", "", false } - if !strings.EqualFold(u.Host, "github.com") && !strings.EqualFold(u.Host, "www.github.com") { - return "", "", fmt.Errorf("only github.com repositories are supported") + host := strings.ToLower(u.Host) + if host != "github.com" && host != "www.github.com" { + return "", "", false } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) < 2 { - return "", "", fmt.Errorf("repository URL must look like https://github.com/{owner}/{repo}") + return "", "", false } - owner := parts[0] - repo := strings.TrimSuffix(parts[1], ".git") + owner = parts[0] + repo = strings.TrimSuffix(parts[1], ".git") if owner == "" || repo == "" { - return "", "", fmt.Errorf("invalid GitHub repository URL") + return "", "", false } - return owner, repo, nil + return owner, repo, true } func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, error) { - if workDir == "" { - workDir = os.TempDir() - } - - dest, err := os.MkdirTemp(workDir, "repo-*") + dest, err := os.MkdirTemp(workDir, "repo-http-*") if err != nil { - return "", err + return "", fmt.Errorf("create temp dir: %w", err) } archiveURL := fmt.Sprintf("https://codeload.github.com/%s/%s/tar.gz", owner, repo) @@ -85,27 +99,23 @@ func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, err req, err := http.NewRequest(http.MethodGet, archiveURL, nil) if err != nil { - return "", err + return "", fmt.Errorf("build archive request: %w", err) } req.Header.Set("User-Agent", "agent-documentor") - client := &http.Client{Timeout: 90 * time.Second} + client := &http.Client{Timeout: httpTimeout} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("download repository archive: %w", err) } - defer func() { - if err := resp.Body.Close(); err != nil { - slog.Error("error closing body", "err", err) - } - }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download repository archive failed: %s", resp.Status) } - if err := untarGz(resp.Body, dest); err != nil { - return "", err + if err := untarGzSafe(resp.Body, dest); err != nil { + return "", fmt.Errorf("extract repository archive: %w", err) } root, err := firstSubdir(dest) @@ -115,34 +125,139 @@ func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, err return root, nil } -func untarGz(r io.Reader, dest string) error { +func cloneRepoWithGit(repoURL, ref, workDir string) (string, error) { + dest, err := os.MkdirTemp(workDir, "repo-git-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + // Initialize empty repo so we can handle branch/tag/sha more flexibly. + if err := runGit(dest, "init"); err != nil { + return "", err + } + if err := runGit(dest, "remote", "add", "origin", repoURL); err != nil { + return "", err + } + + ref = strings.TrimSpace(ref) + + switch { + case ref == "": + // Default branch shallow fetch. + if err := runGit(dest, "fetch", "--depth", "1", "origin"); err != nil { + return "", fmt.Errorf("git fetch default branch: %w", err) + } + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout default branch: %w", err) + } + + case looksLikeCommitish(ref): + // Try exact commit-ish fetch. + if err := runGit(dest, "fetch", "--depth", "1", "origin", ref); err == nil { + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout fetched ref: %w", err) + } + return dest, nil + } + + // Fallback: fetch all refs shallowly and checkout the requested ref. + if err := runGit(dest, "fetch", "--depth", "1", "--tags", "origin"); err != nil { + return "", fmt.Errorf("git fetch tags for ref %q: %w", ref, err) + } + if err := runGit(dest, "fetch", "--depth", "1", "origin", ref); err == nil { + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + return dest, nil + } + if err := runGit(dest, "checkout", ref); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + + default: + // Branch or tag name. + if err := runGit(dest, "fetch", "--depth", "1", "--tags", "origin", ref); err == nil { + if err := runGit(dest, "checkout", "FETCH_HEAD"); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + return dest, nil + } + + if err := runGit(dest, "fetch", "--depth", "1", "--tags", "origin"); err != nil { + return "", fmt.Errorf("git fetch for ref %q: %w", ref, err) + } + if err := runGit(dest, "checkout", ref); err != nil { + return "", fmt.Errorf("git checkout ref %q: %w", ref, err) + } + } + + return dest, nil +} + +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + // Avoid interactive prompts hanging the process. + env := os.Environ() + env = append(env, "GIT_TERMINAL_PROMPT=0") + cmd.Env = env + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func looksLikeCommitish(ref string) bool { + if len(ref) < 7 || len(ref) > 40 { + return false + } + for _, r := range ref { + if !strings.ContainsRune("0123456789abcdefABCDEF", r) { + return false + } + } + return true +} + +func untarGzSafe(r io.Reader, dest string) error { gzr, err := gzip.NewReader(r) if err != nil { return err } - defer func() { - if err := gzr.Close(); err != nil { - slog.Error("error closing body", "err", err) - } - }() + defer gzr.Close() tr := tar.NewReader(gzr) + cleanDest := filepath.Clean(dest) + for { hdr, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { return nil } if err != nil { return err } - target := filepath.Join(dest, filepath.Clean(hdr.Name)) + name := filepath.Clean(hdr.Name) + if name == "." || name == "" { + continue + } + + target := filepath.Join(cleanDest, name) + if !isWithinBase(cleanDest, target) { + return fmt.Errorf("archive entry escapes destination: %q", hdr.Name) + } + switch hdr.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, 0o755); err != nil { return err } - case tar.TypeReg: + + case tar.TypeReg, tar.TypeRegA: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } @@ -150,13 +265,21 @@ func untarGz(r io.Reader, dest string) error { if err != nil { return err } - if _, err := io.Copy(f, tr); err != nil { - _ = f.Close() - return err + _, copyErr := io.Copy(f, tr) + closeErr := f.Close() + if copyErr != nil { + return copyErr } - if err := f.Close(); err != nil { - return err + if closeErr != nil { + return closeErr } + + case tar.TypeSymlink, tar.TypeLink: + // Ignore links for safety/simplicity in v1. + continue + + default: + continue } } } @@ -164,7 +287,7 @@ func untarGz(r io.Reader, dest string) error { func firstSubdir(root string) (string, error) { entries, err := os.ReadDir(root) if err != nil { - return "", err + return "", fmt.Errorf("read extracted repo dir: %w", err) } for _, e := range entries { if e.IsDir() { @@ -174,12 +297,35 @@ func firstSubdir(root string) (string, error) { return "", fmt.Errorf("no extracted repository directory found") } +func resolveSubPath(root, subPath string) (string, error) { + root = filepath.Clean(root) + if strings.TrimSpace(subPath) == "" { + return root, nil + } + + cleanSub := filepath.Clean(subPath) + target := filepath.Join(root, cleanSub) + if !isWithinBase(root, target) { + return "", fmt.Errorf("invalid sub_path: %s", subPath) + } + + info, err := os.Stat(target) + if err != nil { + return "", fmt.Errorf("sub_path not found: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("sub_path is not a directory: %s", subPath) + } + + return target, nil +} + func buildManifest(root string) ([]FileEntry, error) { var manifest []FileEntry - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr } rel, err := filepath.Rel(root, path) @@ -190,19 +336,31 @@ func buildManifest(root string) ([]FileEntry, error) { return nil } - if info.IsDir() { + rel = filepath.ToSlash(rel) + + if d.IsDir() { if shouldSkipDir(rel) { return filepath.SkipDir } return nil } + // Skip symlinks entirely. + if d.Type()&os.ModeSymlink != 0 { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + if !shouldIncludeFile(rel, info.Size()) { return nil } manifest = append(manifest, FileEntry{ - Path: filepath.ToSlash(rel), + Path: rel, Kind: "file", Size: info.Size(), }) @@ -212,18 +370,41 @@ func buildManifest(root string) ([]FileEntry, error) { return nil, err } + sort.Slice(manifest, func(i, j int) bool { + return manifest[i].Path < manifest[j].Path + }) + return manifest, nil } func readRepoFileFromCachedCheckout(localRoot, relPath string) (string, error) { + if strings.TrimSpace(localRoot) == "" { + return "", fmt.Errorf("local repository root is required") + } + if strings.TrimSpace(relPath) == "" { + return "", fmt.Errorf("repository path is required") + } + + base := filepath.Clean(localRoot) cleanRel := filepath.Clean(relPath) - fullPath := filepath.Join(localRoot, cleanRel) + fullPath := filepath.Join(base, cleanRel) - if !strings.HasPrefix(fullPath, filepath.Clean(localRoot)+string(os.PathSeparator)) && - filepath.Clean(fullPath) != filepath.Clean(localRoot) { + if !isWithinBase(base, fullPath) { return "", fmt.Errorf("invalid repository path: %s", relPath) } + // Reject symlinks. + info, err := os.Lstat(fullPath) + if err != nil { + return "", fmt.Errorf("stat repository file %s: %w", relPath, err) + } + if info.IsDir() { + return "", fmt.Errorf("path is a directory, not a file: %s", relPath) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("symlinked files are not supported: %s", relPath) + } + b, err := os.ReadFile(fullPath) if err != nil { return "", fmt.Errorf("read repository file %s: %w", relPath, err) @@ -235,9 +416,20 @@ func readRepoFileFromCachedCheckout(localRoot, relPath string) (string, error) { return string(b), nil } +func isWithinBase(base, target string) bool { + base = filepath.Clean(base) + target = filepath.Clean(target) + + rel, err := filepath.Rel(base, target) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "..") +} + func shouldSkipDir(rel string) bool { switch filepath.Base(rel) { - case ".git", ".github", "vendor", "node_modules", "dist", "build", "bin": + case ".git", ".github", "vendor", "node_modules", "dist", "build", "bin", "coverage", ".next", ".turbo": return true default: return false @@ -245,12 +437,12 @@ func shouldSkipDir(rel string) bool { } func shouldIncludeFile(rel string, size int64) bool { - if size <= 0 || size > 512*1024 { + if size <= 0 || size > maxManifestBytes { return false } switch strings.ToLower(filepath.Ext(rel)) { - case ".go", ".md", ".txt", ".yaml", ".yml", ".json", ".toml", ".proto", ".sql", ".sh": + case ".go", ".md", ".txt", ".yaml", ".yml", ".json", ".toml", ".proto", ".sql", ".sh", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".rb", ".rs", ".c", ".h", ".cpp", ".hpp": return true default: return false diff --git a/agents/documentor/tools.go b/agents/documentor/tools.go index 6d84869..4c0d0b6 100644 --- a/agents/documentor/tools.go +++ b/agents/documentor/tools.go @@ -3,6 +3,7 @@ package documentor import ( "encoding/json" "fmt" + "log/slog" "os" "path/filepath" @@ -29,6 +30,7 @@ type FetchRepoTreeResult struct { func newFetchRepoTreeTool(cfg *Config) func(tool.Context, FetchRepoTreeArgs) (FetchRepoTreeResult, error) { return func(ctx tool.Context, args FetchRepoTreeArgs) (FetchRepoTreeResult, error) { + slog.Info("tool call", "function", "fetch_repo_tree", "args", toJSONString(args)) localPath, manifest, err := fetchRepoManifest(args.RepositoryURL, args.Ref, args.SubPath, cfg.WorkDir) if err != nil { return FetchRepoTreeResult{}, err @@ -78,6 +80,7 @@ type ReadRepoFileResult struct { func newReadRepoFileTool() func(tool.Context, ReadRepoFileArgs) (ReadRepoFileResult, error) { return func(ctx tool.Context, args ReadRepoFileArgs) (ReadRepoFileResult, error) { + slog.Info("tool call", "function", "read_repo_file", "args", toJSONString(args)) v, err := ctx.State().Get(StateRepoLocalPath) if err != nil { return ReadRepoFileResult{}, fmt.Errorf("read repo local path from state: %w", err) @@ -138,6 +141,7 @@ type WriteOutputFileResult struct { func newWriteOutputFileTool() func(tool.Context, WriteOutputFileArgs) (WriteOutputFileResult, error) { return func(ctx tool.Context, args WriteOutputFileArgs) (WriteOutputFileResult, error) { + slog.Info("tool call", "function", "write_output_file", "content_length", len(toJSONString(args))) out := args.OutputPath if out == "" { v, err := ctx.State().Get(StateOutputPath) @@ -182,3 +186,8 @@ func NewWriteOutputTool(_ *Config) (tool.Tool, error) { } return writeOutputTool, nil } + +func toJSONString(v any) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/cmd/documentor.go b/cmd/documentor.go index 046e815..8c9253c 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -33,7 +33,7 @@ func NewDocumentorCmd() *cobra.Command { // Prefer explicit flag, then env vars via Viper. apiKey = viper.GetString("api-key") if apiKey == "" { - return fmt.Errorf("google api key is required; set --api-key or export API_KEY") + return fmt.Errorf("Google Gemini or Claude api key is required; set --api-key or export API_KEY") } if repoURL == "" { return fmt.Errorf("--repo is required") @@ -53,7 +53,6 @@ func NewDocumentorCmd() *cobra.Command { }() cfg := &documentor.Config{WorkDir: workDir} - cfg = cfg.SetDefaults() if err := cfg.Validate(); err != nil { return fmt.Errorf("invalid config: %w", err) } @@ -129,18 +128,17 @@ func NewDocumentorCmd() *cobra.Command { }, } + slog.Info( + "running documentor agent", + "session_id", resp.Session.ID(), + ) + for event, err := range r.Run(ctx, userCLI, resp.Session.ID(), userMsg, agentpkg.RunConfig{}) { if err != nil { return fmt.Errorf("agent error: %w", err) } // handle event (log) - slog.Info("event", "branch", event.Branch) - // Log function calls TODO - // if event.Content != nil && len(event.Content.Parts) != 0 { - // for _, fc := range event.Content.Parts { - // slog.Info("function call", "function", fc.FunctionCall.Name, "input", fc.Text) - // } - // } + slog.Info("event", "id", event.ID, "author", event.Author) } if _, err := os.Stat(output); err != nil { From fbd04e318f8c38d4ac0401289373a75bc09e3cdd Mon Sep 17 00:00:00 2001 From: ATMackay Date: Tue, 17 Mar 2026 18:28:53 +1100 Subject: [PATCH 7/7] fix --- agents/documentor/repo.go | 15 ++++++++++++--- cmd/documentor.go | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/agents/documentor/repo.go b/agents/documentor/repo.go index 928f893..36dc255 100644 --- a/agents/documentor/repo.go +++ b/agents/documentor/repo.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "io/fs" + "log/slog" "net/http" "net/url" "os" @@ -108,7 +109,11 @@ func downloadAndExtractGitHubRepo(owner, repo, ref, workDir string) (string, err if err != nil { return "", fmt.Errorf("download repository archive: %w", err) } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Error("error closing body", "error", err) + } + }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download repository archive failed: %s", resp.Status) @@ -227,7 +232,11 @@ func untarGzSafe(r io.Reader, dest string) error { if err != nil { return err } - defer gzr.Close() + defer func() { + if err := gzr.Close(); err != nil { + slog.Error("error closing body", "error", err) + } + }() tr := tar.NewReader(gzr) cleanDest := filepath.Clean(dest) @@ -257,7 +266,7 @@ func untarGzSafe(r io.Reader, dest string) error { return err } - case tar.TypeReg, tar.TypeRegA: + case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } diff --git a/cmd/documentor.go b/cmd/documentor.go index 8c9253c..532785a 100644 --- a/cmd/documentor.go +++ b/cmd/documentor.go @@ -33,7 +33,7 @@ func NewDocumentorCmd() *cobra.Command { // Prefer explicit flag, then env vars via Viper. apiKey = viper.GetString("api-key") if apiKey == "" { - return fmt.Errorf("Google Gemini or Claude api key is required; set --api-key or export API_KEY") + return fmt.Errorf("google gemini or claude api key is required; set --api-key or export API_KEY") } if repoURL == "" { return fmt.Errorf("--repo is required")