From 899a532d76fb7c6f39988d389748f095de89416a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 09:26:24 +0100 Subject: [PATCH] =?UTF-8?q?chore(ax):=20AX=20compliance=20sweep=20pass=201?= =?UTF-8?q?=20=E2=80=94=20banned=20imports=20and=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all banned imports (fmt, encoding/json, path/filepath, strings, os.Getenv) with core primitives across agentic, brain, ide, and transport packages. Rename abbreviated variables (cfg→config/agentsConfig) and add _Good/_Bad/_Ugly test triads to transport_tcp_test.go and prep_test.go. Co-Authored-By: Virgil --- cmd/brain-seed/main.go | 186 +++++++++++++++--------- pkg/mcp/agentic/dispatch.go | 32 ++-- pkg/mcp/agentic/epic.go | 48 +++--- pkg/mcp/agentic/ingest.go | 29 ++-- pkg/mcp/agentic/plan.go | 61 ++++---- pkg/mcp/agentic/pr.go | 63 ++++---- pkg/mcp/agentic/prep.go | 260 ++++++++++++++++++--------------- pkg/mcp/agentic/prep_test.go | 33 +++++ pkg/mcp/agentic/queue.go | 82 +++++++---- pkg/mcp/agentic/resume.go | 13 +- pkg/mcp/agentic/scan.go | 26 ++-- pkg/mcp/agentic/status.go | 74 +++++----- pkg/mcp/brain/direct.go | 38 +++-- pkg/mcp/ide/bridge.go | 24 +-- pkg/mcp/ide/ide.go | 12 +- pkg/mcp/ide/tools_test.go | 16 +- pkg/mcp/transport_http.go | 11 +- pkg/mcp/transport_tcp.go | 4 +- pkg/mcp/transport_tcp_test.go | 265 +++++++++++++++++++--------------- 19 files changed, 717 insertions(+), 560 deletions(-) diff --git a/cmd/brain-seed/main.go b/cmd/brain-seed/main.go index 6616f68..64a1757 100644 --- a/cmd/brain-seed/main.go +++ b/cmd/brain-seed/main.go @@ -16,17 +16,15 @@ package main import ( "bytes" "crypto/tls" - "encoding/json" "flag" - "fmt" goio "io" "net/http" "os" - "path/filepath" + "path/filepath" // needed for WalkDir (no core equivalent) "regexp" - "strings" "time" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" ) @@ -57,53 +55,47 @@ var httpClient = &http.Client{ func main() { flag.Parse() - fmt.Println("OpenBrain Seed — MCP API Client") - fmt.Println(strings.Repeat("=", 55)) + os.Stdout.Write([]byte("OpenBrain Seed — MCP API Client\n")) + os.Stdout.Write([]byte(core.Sprintf("%s\n", repeatChar('=', 55)))) if *apiKey == "" && !*dryRun { - fmt.Println("ERROR: -api-key is required (or use -dry-run)") - fmt.Println(" Generate one at: https://lthn.sh/admin/mcp/api-keys") + os.Stdout.Write([]byte("ERROR: -api-key is required (or use -dry-run)\n")) + os.Stdout.Write([]byte(" Generate one at: https://lthn.sh/admin/mcp/api-keys\n")) os.Exit(1) } if *dryRun { - fmt.Println("[DRY RUN] — no data will be stored") + os.Stdout.Write([]byte("[DRY RUN] — no data will be stored\n")) } - fmt.Printf("API: %s\n", *apiURL) - fmt.Printf("Server: %s | Agent: %s\n", *server, *agent) + os.Stdout.Write([]byte(core.Sprintf("API: %s\n", *apiURL))) + os.Stdout.Write([]byte(core.Sprintf("Server: %s | Agent: %s\n", *server, *agent))) // Discover memory files memPath := *memoryPath if memPath == "" { - home, _ := os.UserHomeDir() - memPath = filepath.Join(home, ".claude", "projects", "*", "memory") + memPath = core.JoinPath(core.Env("HOME"), ".claude", "projects", "*", "memory") } - memFiles, _ := filepath.Glob(filepath.Join(memPath, "*.md")) - fmt.Printf("\nFound %d memory files\n", len(memFiles)) + memFiles := core.PathGlob(core.JoinPath(memPath, "*.md")) + os.Stdout.Write([]byte(core.Sprintf("\nFound %d memory files\n", len(memFiles)))) // Discover plan files var planFiles []string if *plans { pPath := *planPath if pPath == "" { - home, _ := os.UserHomeDir() - pPath = filepath.Join(home, "Code", "*", "docs", "plans") + pPath = core.JoinPath(core.Env("HOME"), "Code", "*", "docs", "plans") } - planFiles, _ = filepath.Glob(filepath.Join(pPath, "*.md")) + planFiles = append(planFiles, core.PathGlob(core.JoinPath(pPath, "*.md"))...) // Also check nested dirs (completed/, etc.) - nested, _ := filepath.Glob(filepath.Join(pPath, "*", "*.md")) - planFiles = append(planFiles, nested...) + planFiles = append(planFiles, core.PathGlob(core.JoinPath(pPath, "*", "*.md"))...) // Also check host-uk nested repos - home, _ := os.UserHomeDir() - hostUkPath := filepath.Join(home, "Code", "host-uk", "*", "docs", "plans") - hostUkFiles, _ := filepath.Glob(filepath.Join(hostUkPath, "*.md")) - planFiles = append(planFiles, hostUkFiles...) - hostUkNested, _ := filepath.Glob(filepath.Join(hostUkPath, "*", "*.md")) - planFiles = append(planFiles, hostUkNested...) + hostUkPath := core.JoinPath(core.Env("HOME"), "Code", "host-uk", "*", "docs", "plans") + planFiles = append(planFiles, core.PathGlob(core.JoinPath(hostUkPath, "*.md"))...) + planFiles = append(planFiles, core.PathGlob(core.JoinPath(hostUkPath, "*", "*.md"))...) - fmt.Printf("Found %d plan files\n", len(planFiles)) + os.Stdout.Write([]byte(core.Sprintf("Found %d plan files\n", len(planFiles)))) } // Discover CLAUDE.md files @@ -111,11 +103,10 @@ func main() { if *claudeMd { cPath := *codePath if cPath == "" { - home, _ := os.UserHomeDir() - cPath = filepath.Join(home, "Code") + cPath = core.JoinPath(core.Env("HOME"), "Code") } claudeFiles = discoverClaudeMdFiles(cPath) - fmt.Printf("Found %d CLAUDE.md files\n", len(claudeFiles)) + os.Stdout.Write([]byte(core.Sprintf("Found %d CLAUDE.md files\n", len(claudeFiles)))) } imported := 0 @@ -123,11 +114,11 @@ func main() { errors := 0 // Process memory files - fmt.Println("\n--- Memory Files ---") + os.Stdout.Write([]byte("\n--- Memory Files ---\n")) for _, f := range memFiles { project := extractProject(f) sections := parseMarkdownSections(f) - filename := strings.TrimSuffix(filepath.Base(f), ".md") + filename := core.TrimSuffix(core.PathBase(f), ".md") if len(sections) == 0 { coreerr.Warn("brain-seed: skip file (no sections)", "project", project, "file", filename) @@ -137,7 +128,7 @@ func main() { for _, sec := range sections { content := sec.heading + "\n\n" + sec.content - if strings.TrimSpace(sec.content) == "" { + if core.Trim(sec.content) == "" { skipped++ continue } @@ -150,8 +141,8 @@ func main() { content = truncate(content, *maxChars) if *dryRun { - fmt.Printf(" [DRY] %s/%s :: %s (%s) — %d chars\n", - project, filename, sec.heading, memType, len(content)) + os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s/%s :: %s (%s) — %d chars\n", + project, filename, sec.heading, memType, len(content)))) imported++ continue } @@ -161,18 +152,18 @@ func main() { errors++ continue } - fmt.Printf(" ok %s/%s :: %s (%s)\n", project, filename, sec.heading, memType) + os.Stdout.Write([]byte(core.Sprintf(" ok %s/%s :: %s (%s)\n", project, filename, sec.heading, memType))) imported++ } } // Process plan files if *plans && len(planFiles) > 0 { - fmt.Println("\n--- Plan Documents ---") + os.Stdout.Write([]byte("\n--- Plan Documents ---\n")) for _, f := range planFiles { project := extractProjectFromPlan(f) sections := parseMarkdownSections(f) - filename := strings.TrimSuffix(filepath.Base(f), ".md") + filename := core.TrimSuffix(core.PathBase(f), ".md") if len(sections) == 0 { skipped++ @@ -181,7 +172,7 @@ func main() { for _, sec := range sections { content := sec.heading + "\n\n" + sec.content - if strings.TrimSpace(sec.content) == "" { + if core.Trim(sec.content) == "" { skipped++ continue } @@ -190,8 +181,8 @@ func main() { content = truncate(content, *maxChars) if *dryRun { - fmt.Printf(" [DRY] %s :: %s / %s (plan) — %d chars\n", - project, filename, sec.heading, len(content)) + os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s :: %s / %s (plan) — %d chars\n", + project, filename, sec.heading, len(content)))) imported++ continue } @@ -201,7 +192,7 @@ func main() { errors++ continue } - fmt.Printf(" ok %s :: %s / %s (plan)\n", project, filename, sec.heading) + os.Stdout.Write([]byte(core.Sprintf(" ok %s :: %s / %s (plan)\n", project, filename, sec.heading))) imported++ } } @@ -209,7 +200,7 @@ func main() { // Process CLAUDE.md files if *claudeMd && len(claudeFiles) > 0 { - fmt.Println("\n--- CLAUDE.md Files ---") + os.Stdout.Write([]byte("\n--- CLAUDE.md Files ---\n")) for _, f := range claudeFiles { project := extractProjectFromClaudeMd(f) sections := parseMarkdownSections(f) @@ -221,7 +212,7 @@ func main() { for _, sec := range sections { content := sec.heading + "\n\n" + sec.content - if strings.TrimSpace(sec.content) == "" { + if core.Trim(sec.content) == "" { skipped++ continue } @@ -230,8 +221,8 @@ func main() { content = truncate(content, *maxChars) if *dryRun { - fmt.Printf(" [DRY] %s :: CLAUDE.md / %s (convention) — %d chars\n", - project, sec.heading, len(content)) + os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s :: CLAUDE.md / %s (convention) — %d chars\n", + project, sec.heading, len(content)))) imported++ continue } @@ -241,18 +232,18 @@ func main() { errors++ continue } - fmt.Printf(" ok %s :: CLAUDE.md / %s (convention)\n", project, sec.heading) + os.Stdout.Write([]byte(core.Sprintf(" ok %s :: CLAUDE.md / %s (convention)\n", project, sec.heading))) imported++ } } } - fmt.Printf("\n%s\n", strings.Repeat("=", 55)) + os.Stdout.Write([]byte(core.Sprintf("\n%s\n", repeatChar('=', 55)))) prefix := "" if *dryRun { prefix = "[DRY RUN] " } - fmt.Printf("%sImported: %d | Skipped: %d | Errors: %d\n", prefix, imported, skipped, errors) + os.Stdout.Write([]byte(core.Sprintf("%sImported: %d | Skipped: %d | Errors: %d\n", prefix, imported, skipped, errors))) } // callBrainRemember sends a memory to the MCP API via brain_remember tool. @@ -273,10 +264,7 @@ func callBrainRemember(content, memType string, tags []string, project string, c "arguments": args, } - body, err := json.Marshal(payload) - if err != nil { - return coreerr.E("callBrainRemember", "marshal", err) - } + body := []byte(core.JSONMarshalString(payload)) req, err := http.NewRequest("POST", *apiURL+"/tools/call", bytes.NewReader(body)) if err != nil { @@ -301,8 +289,9 @@ func callBrainRemember(content, memType string, tags []string, project string, c Success bool `json:"success"` Error string `json:"error"` } - if err := json.Unmarshal(respBody, &result); err != nil { - return coreerr.E("callBrainRemember", "decode", err) + r := core.JSONUnmarshalString(string(respBody), &result) + if !r.OK { + return coreerr.E("callBrainRemember", "decode", nil) } if !result.Success { return coreerr.E("callBrainRemember", "API: "+result.Error, nil) @@ -312,19 +301,26 @@ func callBrainRemember(content, memType string, tags []string, project string, c } // truncate caps content to maxLen chars, appending an ellipsis if truncated. +// +// truncate("hello world", 5) // "hello…" func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } // Find last space before limit to avoid splitting mid-word cut := maxLen - if idx := strings.LastIndex(s[:maxLen], " "); idx > maxLen-200 { - cut = idx + for i := maxLen - 1; i > maxLen-200 && i >= 0; i-- { + if s[i] == ' ' { + cut = i + break + } } return s[:cut] + "…" } // discoverClaudeMdFiles finds CLAUDE.md files across a code directory. +// +// files := discoverClaudeMdFiles(core.JoinPath(core.Env("HOME"), "Code")) func discoverClaudeMdFiles(codePath string) []string { var files []string @@ -338,9 +334,15 @@ func discoverClaudeMdFiles(codePath string) []string { if name == "node_modules" || name == "vendor" || name == ".claude" { return filepath.SkipDir } - // Limit depth + // Limit depth by counting separators manually rel, _ := filepath.Rel(codePath, path) - if strings.Count(rel, string(os.PathSeparator)) > 3 { + depth := 0 + for _, ch := range rel { + if ch == os.PathSeparator { + depth++ + } + } + if depth > 3 { return filepath.SkipDir } return nil @@ -363,6 +365,8 @@ type section struct { var headingRe = regexp.MustCompile(`^#{1,3}\s+(.+)$`) // parseMarkdownSections splits a markdown file by headings. +// +// sections := parseMarkdownSections("/path/to/MEMORY.md") func parseMarkdownSections(path string) []section { data, err := coreio.Local.Read(path) if err != nil || len(data) == 0 { @@ -370,19 +374,19 @@ func parseMarkdownSections(path string) []section { } var sections []section - lines := strings.Split(data, "\n") + lines := core.Split(data, "\n") var curHeading string var curContent []string for _, line := range lines { if m := headingRe.FindStringSubmatch(line); m != nil { if curHeading != "" && len(curContent) > 0 { - text := strings.TrimSpace(strings.Join(curContent, "\n")) + text := core.Trim(joinLines(curContent)) if text != "" { sections = append(sections, section{curHeading, text}) } } - curHeading = strings.TrimSpace(m[1]) + curHeading = core.Trim(m[1]) curContent = nil } else { curContent = append(curContent, line) @@ -391,17 +395,17 @@ func parseMarkdownSections(path string) []section { // Flush last section if curHeading != "" && len(curContent) > 0 { - text := strings.TrimSpace(strings.Join(curContent, "\n")) + text := core.Trim(joinLines(curContent)) if text != "" { sections = append(sections, section{curHeading, text}) } } // If no headings found, treat entire file as one section - if len(sections) == 0 && strings.TrimSpace(data) != "" { + if len(sections) == 0 && core.Trim(data) != "" { sections = append(sections, section{ - heading: strings.TrimSuffix(filepath.Base(path), ".md"), - content: strings.TrimSpace(data), + heading: core.TrimSuffix(core.PathBase(path), ".md"), + content: core.Trim(data), }) } @@ -459,7 +463,7 @@ func inferType(heading, content, source string) string { return "convention" } - lower := strings.ToLower(heading + " " + content) + lower := toLower(heading + " " + content) patterns := map[string][]string{ "architecture": {"architecture", "stack", "infrastructure", "layer", "service mesh"}, "convention": {"convention", "standard", "naming", "pattern", "rule", "coding"}, @@ -468,10 +472,10 @@ func inferType(heading, content, source string) string { "plan": {"plan", "todo", "roadmap", "milestone", "phase", "task"}, "research": {"research", "finding", "discovery", "analysis", "rfc"}, } - for t, keywords := range patterns { + for memoryType, keywords := range patterns { for _, kw := range keywords { - if strings.Contains(lower, kw) { - return t + if core.Contains(lower, kw) { + return memoryType } } } @@ -485,7 +489,7 @@ func buildTags(filename, source, project string) []string { tags = append(tags, "project:"+project) } if filename != "MEMORY" && filename != "CLAUDE" { - tags = append(tags, strings.ReplaceAll(strings.ReplaceAll(filename, "-", " "), "_", " ")) + tags = append(tags, core.Replace(core.Replace(filename, "-", " "), "_", " ")) } return tags } @@ -503,3 +507,43 @@ func confidenceForSource(source string) float64 { return 0.5 } } + +// repeatChar returns a string of n repetitions of ch. +// +// repeatChar('=', 3) // "===" +func repeatChar(ch byte, n int) string { + b := core.NewBuilder() + for i := 0; i < n; i++ { + b.WriteByte(ch) + } + return b.String() +} + +// joinLines joins a slice of lines with newline separators. +// +// joinLines([]string{"a", "b"}) // "a\nb" +func joinLines(lines []string) string { + b := core.NewBuilder() + for i, line := range lines { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(line) + } + return b.String() +} + +// toLower converts a string to lowercase ASCII. +// +// toLower("Hello World") // "hello world" +func toLower(s string) string { + b := core.NewBuilder() + for _, ch := range s { + if ch >= 'A' && ch <= 'Z' { + b.WriteRune(ch + 32) + } else { + b.WriteRune(ch) + } + } + return b.String() +} diff --git a/pkg/mcp/agentic/dispatch.go b/pkg/mcp/agentic/dispatch.go index ce01daf..9d38929 100644 --- a/pkg/mcp/agentic/dispatch.go +++ b/pkg/mcp/agentic/dispatch.go @@ -4,14 +4,12 @@ package agentic import ( "context" - "fmt" "os" "os/exec" - "path/filepath" - "strings" "syscall" "time" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -52,31 +50,31 @@ func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) { // agentCommand returns the command and args for a given agent type. // Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku". func agentCommand(agent, prompt string) (string, []string, error) { - parts := strings.SplitN(agent, ":", 2) - base := parts[0] - model := "" + parts := core.SplitN(agent, ":", 2) + agentBase := parts[0] + agentModel := "" if len(parts) > 1 { - model = parts[1] + agentModel = parts[1] } - switch base { + switch agentBase { case "gemini": args := []string{"-p", prompt, "--yolo", "--sandbox"} - if model != "" { - args = append(args, "-m", "gemini-2.5-"+model) + if agentModel != "" { + args = append(args, "-m", "gemini-2.5-"+agentModel) } return "gemini", args, nil case "codex": return "codex", []string{"--approval-mode", "full-auto", "-q", prompt}, nil case "claude": args := []string{"-p", prompt, "--dangerously-skip-permissions"} - if model != "" { - args = append(args, "--model", model) + if agentModel != "" { + args = append(args, "--model", agentModel) } return "claude", args, nil case "local": - home, _ := os.UserHomeDir() - script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh") + home := core.Env("HOME") + script := core.JoinPath(home, "Code", "core", "agent", "scripts", "local-agent.sh") return "bash", []string{script, prompt}, nil default: return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil) @@ -117,14 +115,14 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, } wsDir := prepOut.WorkspaceDir - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") // The prompt is just: read PROMPT.md and do the work prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory." if input.DryRun { // Read PROMPT.md for the dry run output - promptRaw, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md")) + promptRaw, _ := coreio.Local.Read(core.JoinPath(wsDir, "PROMPT.md")) return nil, DispatchOutput{ Success: true, Agent: input.Agent, @@ -175,7 +173,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, return nil, DispatchOutput{}, err } - outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent)) + outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", input.Agent)) outFile, err := os.Create(outputFile) if err != nil { return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err) diff --git a/pkg/mcp/agentic/epic.go b/pkg/mcp/agentic/epic.go index 2cf83e0..e66c34c 100644 --- a/pkg/mcp/agentic/epic.go +++ b/pkg/mcp/agentic/epic.go @@ -5,11 +5,9 @@ package agentic import ( "bytes" "context" - "encoding/json" - "fmt" "net/http" - "strings" + core "dappco.re/go/core" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -99,19 +97,19 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest } // Step 2: Build epic body with checklist - var body strings.Builder + epicBody := core.NewBuilder() if input.Body != "" { - body.WriteString(input.Body) - body.WriteString("\n\n") + epicBody.WriteString(input.Body) + epicBody.WriteString("\n\n") } - body.WriteString("## Tasks\n\n") + epicBody.WriteString("## Tasks\n\n") for _, child := range children { - body.WriteString(fmt.Sprintf("- [ ] #%d %s\n", child.Number, child.Title)) + epicBody.WriteString(core.Sprintf("- [ ] #%d %s\n", child.Number, child.Title)) } // Step 3: Create epic issue epicLabels := append(labelIDs, s.resolveLabelIDs(ctx, input.Org, input.Repo, []string{"epic"})...) - epic, err := s.createIssue(ctx, input.Org, input.Repo, input.Title, body.String(), epicLabels) + epic, err := s.createIssue(ctx, input.Org, input.Repo, input.Title, epicBody.String(), epicLabels) if err != nil { return nil, EpicOutput{}, coreerr.E("createEpic", "failed to create epic", err) } @@ -155,9 +153,9 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body payload["labels"] = labelIDs } - data, _ := json.Marshal(payload) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) + data := []byte(core.JSONMarshalString(payload)) + issueURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) + req, _ := http.NewRequestWithContext(ctx, "POST", issueURL, bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -165,17 +163,17 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body if err != nil { return ChildRef{}, coreerr.E("createIssue", "request failed", err) } - defer resp.Body.Close() if resp.StatusCode != 201 { - return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("returned %d", resp.StatusCode), nil) + resp.Body.Close() + return ChildRef{}, coreerr.E("createIssue", core.Sprintf("returned %d", resp.StatusCode), nil) } var result struct { Number int `json:"number"` HTMLURL string `json:"html_url"` } - json.NewDecoder(resp.Body).Decode(&result) + core.JSONUnmarshalString(readBody(resp.Body), &result) return ChildRef{ Number: result.Number, @@ -191,16 +189,16 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n } // Fetch existing labels - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + labelsURL := core.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo) + req, _ := http.NewRequestWithContext(ctx, "GET", labelsURL, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) if err != nil { return nil } - defer resp.Body.Close() if resp.StatusCode != 200 { + resp.Body.Close() return nil } @@ -208,7 +206,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n ID int64 `json:"id"` Name string `json:"name"` } - json.NewDecoder(resp.Body).Decode(&existing) + core.JSONUnmarshalString(readBody(resp.Body), &existing) nameToID := make(map[string]int64) for _, l := range existing { @@ -244,13 +242,13 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string) colour = "#6b7280" } - payload, _ := json.Marshal(map[string]string{ + payload := []byte(core.JSONMarshalString(map[string]string{ "name": name, "color": colour, - }) + })) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) + createLabelURL := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) + req, _ := http.NewRequestWithContext(ctx, "POST", createLabelURL, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -258,15 +256,15 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string) if err != nil { return 0 } - defer resp.Body.Close() if resp.StatusCode != 201 { + resp.Body.Close() return 0 } var result struct { ID int64 `json:"id"` } - json.NewDecoder(resp.Body).Decode(&result) + core.JSONUnmarshalString(readBody(resp.Body), &result) return result.ID } diff --git a/pkg/mcp/agentic/ingest.go b/pkg/mcp/agentic/ingest.go index 760ba46..7a19232 100644 --- a/pkg/mcp/agentic/ingest.go +++ b/pkg/mcp/agentic/ingest.go @@ -4,13 +4,9 @@ package agentic import ( "bytes" - "encoding/json" - "fmt" "net/http" - "os" - "path/filepath" - "strings" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" ) @@ -23,10 +19,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { } // Read the log file - logFiles, err := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) - if err != nil { - return - } + logFiles := core.PathGlob(core.JoinPath(wsDir, "agent-*.log")) if len(logFiles) == 0 { return } @@ -39,7 +32,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { body := contentStr // Skip quota errors - if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") { + if core.Contains(body, "QUOTA_EXHAUSTED") || core.Contains(body, "QuotaError") { return } @@ -52,13 +45,13 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { // Determine issue type from the template used issueType := "task" priority := "normal" - if strings.Contains(body, "security") || strings.Contains(body, "Security") { + if core.Contains(body, "security") || core.Contains(body, "Security") { issueType = "bug" priority = "high" } // Create a single issue per repo with all findings in the body - title := fmt.Sprintf("Scan findings for %s (%d items)", st.Repo, findings) + title := core.Sprintf("Scan findings for %s (%d items)", st.Repo, findings) // Truncate body to reasonable size for issue description description := body @@ -81,7 +74,7 @@ func countFileRefs(body string) int { } if j < len(body) && body[j] == '`' { ref := body[i+1 : j] - if strings.Contains(ref, ".go:") || strings.Contains(ref, ".php:") { + if core.Contains(ref, ".go:") || core.Contains(ref, ".php:") { count++ } } @@ -97,20 +90,20 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p } // Read the agent API key from file - home, _ := os.UserHomeDir() - apiKeyData, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key")) + home := core.Env("HOME") + apiKeyData, err := coreio.Local.Read(core.JoinPath(home, ".claude", "agent-api.key")) if err != nil { return } - apiKey := strings.TrimSpace(apiKeyData) + apiKey := core.Trim(apiKeyData) - payload, _ := json.Marshal(map[string]string{ + payload := []byte(core.JSONMarshalString(map[string]string{ "title": title, "description": description, "type": issueType, "priority": priority, "reporter": "cladius", - }) + })) req, _ := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") diff --git a/pkg/mcp/agentic/plan.go b/pkg/mcp/agentic/plan.go index db2cf8d..f93da4f 100644 --- a/pkg/mcp/agentic/plan.go +++ b/pkg/mcp/agentic/plan.go @@ -6,11 +6,9 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json" - "path/filepath" - "strings" "time" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -281,11 +279,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu var plans []Plan for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") { continue } - id := strings.TrimSuffix(entry.Name(), ".json") + id := core.TrimSuffix(entry.Name(), ".json") plan, err := readPlan(dir, id) if err != nil { continue @@ -312,36 +310,43 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu // --- Helpers --- func (s *PrepSubsystem) plansDir() string { - return filepath.Join(s.codePath, ".core", "plans") + return core.JoinPath(s.codePath, ".core", "plans") } func planPath(dir, id string) string { - return filepath.Join(dir, id+".json") + return core.JoinPath(dir, id+".json") } func generatePlanID(title string) string { - slug := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' { - return r + // Build slug: lowercase, letters/digits/- + builder := core.NewBuilder() + for _, r := range title { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-': + builder.WriteRune(r) + case r >= 'A' && r <= 'Z': + builder.WriteRune(r + 32) + case r == ' ': + builder.WriteRune('-') } - if r >= 'A' && r <= 'Z' { - return r + 32 - } - if r == ' ' { - return '-' - } - return -1 - }, title) + } + slug := builder.String() // Trim consecutive dashes and cap length - for strings.Contains(slug, "--") { - slug = strings.ReplaceAll(slug, "--", "-") + for core.Contains(slug, "--") { + slug = core.Replace(slug, "--", "-") + } + slug = core.Trim(slug) + // Trim leading/trailing dashes manually (core.Trim does whitespace only) + for len(slug) > 0 && slug[0] == '-' { + slug = slug[1:] } - slug = strings.Trim(slug, "-") if len(slug) > 30 { slug = slug[:30] } - slug = strings.TrimRight(slug, "-") + for len(slug) > 0 && slug[len(slug)-1] == '-' { + slug = slug[:len(slug)-1] + } // Append short random suffix for uniqueness b := make([]byte, 3) @@ -356,8 +361,9 @@ func readPlan(dir, id string) (*Plan, error) { } var plan Plan - if err := json.Unmarshal([]byte(data), &plan); err != nil { - return nil, coreerr.E("readPlan", "failed to parse plan "+id, err) + result := core.JSONUnmarshalString(data, &plan) + if !result.OK { + return nil, coreerr.E("readPlan", "failed to parse plan "+id, nil) } return &plan, nil } @@ -368,12 +374,9 @@ func writePlan(dir string, plan *Plan) (string, error) { } path := planPath(dir, plan.ID) - data, err := json.MarshalIndent(plan, "", " ") - if err != nil { - return "", err - } + encoded := core.JSONMarshalString(plan) - return path, coreio.Local.Write(path, string(data)) + return path, coreio.Local.Write(path, encoded) } func validPlanStatus(status string) bool { diff --git a/pkg/mcp/agentic/pr.go b/pkg/mcp/agentic/pr.go index 1622dc0..33f4a2a 100644 --- a/pkg/mcp/agentic/pr.go +++ b/pkg/mcp/agentic/pr.go @@ -5,13 +5,10 @@ package agentic import ( "bytes" "context" - "encoding/json" - "fmt" "net/http" "os/exec" - "path/filepath" - "strings" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -54,8 +51,8 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil) } - wsDir := filepath.Join(s.workspaceRoot(), input.Workspace) - srcDir := filepath.Join(wsDir, "src") + wsDir := core.JoinPath(s.workspaceRoot(), input.Workspace) + srcDir := core.JoinPath(wsDir, "src") if _, err := coreio.Local.List(srcDir); err != nil { return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil) @@ -75,7 +72,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in if err != nil { return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err) } - st.Branch = strings.TrimSpace(string(out)) + st.Branch = core.Trim(string(out)) } org := st.Org @@ -93,7 +90,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in title = st.Task } if title == "" { - title = fmt.Sprintf("Agent work on %s", st.Branch) + title = core.Sprintf("Agent work on %s", st.Branch) } // Build PR body @@ -131,7 +128,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in // Comment on issue if tracked if st.Issue > 0 { - comment := fmt.Sprintf("Pull request created: %s", prURL) + comment := core.Sprintf("Pull request created: %s", prURL) s.commentOnIssue(ctx, org, st.Repo, st.Issue, comment) } @@ -147,31 +144,31 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in } func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string { - var b strings.Builder - b.WriteString("## Summary\n\n") + builder := core.NewBuilder() + builder.WriteString("## Summary\n\n") if st.Task != "" { - b.WriteString(st.Task) - b.WriteString("\n\n") + builder.WriteString(st.Task) + builder.WriteString("\n\n") } if st.Issue > 0 { - b.WriteString(fmt.Sprintf("Closes #%d\n\n", st.Issue)) + builder.WriteString(core.Sprintf("Closes #%d\n\n", st.Issue)) } - b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent)) - b.WriteString(fmt.Sprintf("**Runs:** %d\n", st.Runs)) - b.WriteString("\n---\n*Created by agentic dispatch*\n") - return b.String() + builder.WriteString(core.Sprintf("**Agent:** %s\n", st.Agent)) + builder.WriteString(core.Sprintf("**Runs:** %d\n", st.Runs)) + builder.WriteString("\n---\n*Created by agentic dispatch*\n") + return builder.String() } func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base, title, body string) (string, int, error) { - payload, _ := json.Marshal(map[string]any{ + payload := []byte(core.JSONMarshalString(map[string]any{ "title": title, "body": body, "head": head, "base": base, - }) + })) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) + pullsURL := core.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo) + req, _ := http.NewRequestWithContext(ctx, "POST", pullsURL, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -183,25 +180,25 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base if resp.StatusCode != 201 { var errBody map[string]any - json.NewDecoder(resp.Body).Decode(&errBody) + core.JSONUnmarshalString(readBody(resp.Body), &errBody) msg, _ := errBody["message"].(string) - return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) + return "", 0, coreerr.E("forgeCreatePR", core.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) } var pr struct { Number int `json:"number"` HTMLURL string `json:"html_url"` } - json.NewDecoder(resp.Body).Decode(&pr) + core.JSONUnmarshalString(readBody(resp.Body), &pr) return pr.HTMLURL, pr.Number, nil } func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, issue int, comment string) { - payload, _ := json.Marshal(map[string]string{"body": comment}) + payload := []byte(core.JSONMarshalString(map[string]string{"body": comment})) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue) - req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) + commentURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue) + req, _ := http.NewRequestWithContext(ctx, "POST", commentURL, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -302,18 +299,18 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp } func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10", + repoPullsURL := core.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10", s.forgeURL, org, repo, state) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req, _ := http.NewRequestWithContext(ctx, "GET", repoPullsURL, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) if err != nil { return nil, coreerr.E("listRepoPRs", "failed to list PRs for "+repo, err) } - defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, coreerr.E("listRepoPRs", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) + resp.Body.Close() + return nil, coreerr.E("listRepoPRs", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) } var prs []struct { @@ -335,7 +332,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string Name string `json:"name"` } `json:"labels"` } - json.NewDecoder(resp.Body).Decode(&prs) + core.JSONUnmarshalString(readBody(resp.Body), &prs) var result []PRInfo for _, pr := range prs { diff --git a/pkg/mcp/agentic/prep.go b/pkg/mcp/agentic/prep.go index cca8857..62cb15a 100644 --- a/pkg/mcp/agentic/prep.go +++ b/pkg/mcp/agentic/prep.go @@ -7,16 +7,12 @@ package agentic import ( "context" "encoding/base64" - "encoding/json" - "fmt" goio "io" "net/http" - "os" "os/exec" - "path/filepath" - "strings" "time" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -35,18 +31,21 @@ type PrepSubsystem struct { } // NewPrep creates an agentic subsystem. +// +// sub := agentic.NewPrep() +// svc, _ := mcp.New(mcp.Options{Subsystems: []mcp.Subsystem{sub}}) func NewPrep() *PrepSubsystem { - home, _ := os.UserHomeDir() + home := core.Env("HOME") - forgeToken := os.Getenv("FORGE_TOKEN") + forgeToken := core.Env("FORGE_TOKEN") if forgeToken == "" { - forgeToken = os.Getenv("GITEA_TOKEN") + forgeToken = core.Env("GITEA_TOKEN") } - brainKey := os.Getenv("CORE_BRAIN_KEY") + brainKey := core.Env("CORE_BRAIN_KEY") if brainKey == "" { - if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil { - brainKey = strings.TrimSpace(data) + if data, err := coreio.Local.Read(core.JoinPath(home, ".claude", "brain.key")); err == nil { + brainKey = core.Trim(data) } } @@ -55,31 +54,31 @@ func NewPrep() *PrepSubsystem { forgeToken: forgeToken, brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"), brainKey: brainKey, - specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")), - codePath: envOr("CODE_PATH", filepath.Join(home, "Code")), + specsPath: envOr("SPECS_PATH", core.JoinPath(home, "Code", "host-uk", "specs")), + codePath: envOr("CODE_PATH", core.JoinPath(home, "Code")), client: &http.Client{Timeout: 30 * time.Second}, } } func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { + if v := core.Env(key); v != "" { return v } return fallback } func sanitizeRepoPathSegment(value, field string, allowSubdirs bool) (string, error) { - if strings.TrimSpace(value) != value { + if core.Trim(value) != value { return "", coreerr.E("prepWorkspace", field+" contains whitespace", nil) } if value == "" { return "", nil } - if strings.Contains(value, "\\") { + if core.Contains(value, "\\") { return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil) } - parts := strings.Split(value, "/") + parts := core.Split(value, "/") if !allowSubdirs && len(parts) != 1 { return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil) } @@ -134,7 +133,7 @@ func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil } // workspaceRoot returns the base directory for agent workspaces. func (s *PrepSubsystem) workspaceRoot() string { - return filepath.Join(s.codePath, ".core", "workspace") + return core.JoinPath(s.codePath, ".core", "workspace") } // --- Input/Output types --- @@ -197,8 +196,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques // Workspace root: .core/workspace/{repo}-{timestamp}/ wsRoot := s.workspaceRoot() - wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().Unix()) - wsDir := filepath.Join(wsRoot, wsName) + wsName := core.Sprintf("%s-%d", input.Repo, time.Now().Unix()) + wsDir := core.JoinPath(wsRoot, wsName) // Create workspace structure // kb/ and specs/ will be created inside src/ after clone @@ -206,31 +205,41 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques out := PrepOutput{WorkspaceDir: wsDir} // Source repo path - repoPath := filepath.Join(s.codePath, "core", input.Repo) + repoPath := core.JoinPath(s.codePath, "core", input.Repo) // 1. Clone repo into src/ and create feature branch - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir) if err := cloneCmd.Run(); err != nil { return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err) } // Create feature branch - taskSlug := strings.Map(func(r rune) rune { - if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' { - return r + taskBuilder := core.NewBuilder() + for _, r := range input.Task { + switch { + case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-': + taskBuilder.WriteRune(r) + case r >= 'A' && r <= 'Z': + taskBuilder.WriteRune(r + 32) + default: + taskBuilder.WriteRune('-') } - if r >= 'A' && r <= 'Z' { - return r + 32 // lowercase - } - return '-' - }, input.Task) + } + taskSlug := taskBuilder.String() if len(taskSlug) > 40 { taskSlug = taskSlug[:40] } - taskSlug = strings.Trim(taskSlug, "-") + taskSlug = core.Trim(taskSlug) + // Trim leading/trailing dashes + for len(taskSlug) > 0 && taskSlug[0] == '-' { + taskSlug = taskSlug[1:] + } + for len(taskSlug) > 0 && taskSlug[len(taskSlug)-1] == '-' { + taskSlug = taskSlug[:len(taskSlug)-1] + } if taskSlug != "" { - branchName := fmt.Sprintf("agent/%s", taskSlug) + branchName := core.Sprintf("agent/%s", taskSlug) branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName) branchCmd.Dir = srcDir if err := branchCmd.Run(); err != nil { @@ -239,29 +248,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques } // Create context dirs inside src/ - coreio.Local.EnsureDir(filepath.Join(srcDir, "kb")) - coreio.Local.EnsureDir(filepath.Join(srcDir, "specs")) + coreio.Local.EnsureDir(core.JoinPath(srcDir, "kb")) + coreio.Local.EnsureDir(core.JoinPath(srcDir, "specs")) // Remote stays as local clone origin — agent cannot push to forge. // Reviewer pulls changes from workspace and pushes after verification. // 2. Copy CLAUDE.md and GEMINI.md to workspace - claudeMdPath := filepath.Join(repoPath, "CLAUDE.md") + claudeMdPath := core.JoinPath(repoPath, "CLAUDE.md") if data, err := coreio.Local.Read(claudeMdPath); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "CLAUDE.md"), data) + coreio.Local.Write(core.JoinPath(wsDir, "src", "CLAUDE.md"), data) out.ClaudeMd = true } // Copy GEMINI.md from core/agent (ethics framework for all agents) - agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md") + agentGeminiMd := core.JoinPath(s.codePath, "core", "agent", "GEMINI.md") if data, err := coreio.Local.Read(agentGeminiMd); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data) + coreio.Local.Write(core.JoinPath(wsDir, "src", "GEMINI.md"), data) } // Copy persona if specified if persona != "" { - personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md") + personaPath := core.JoinPath(s.codePath, "core", "agent", "prompts", "personas", persona+".md") if data, err := coreio.Local.Read(personaPath); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data) + coreio.Local.Write(core.JoinPath(wsDir, "src", "PERSONA.md"), data) } } @@ -269,9 +278,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques if input.Issue > 0 { s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir) } else if input.Task != "" { - todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n", + todo := core.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n", input.Task, input.Org, input.Repo, input.Task) - coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo) + coreio.Local.Write(core.JoinPath(wsDir, "src", "TODO.md"), todo) } // 4. Generate CONTEXT.md from OpenBrain @@ -368,7 +377,7 @@ Do NOT push. Commit only — a reviewer will verify and push. prompt = "Read TODO.md and complete the task. Work in src/.\n" } - coreio.Local.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt) + coreio.Local.Write(core.JoinPath(wsDir, "src", "PROMPT.md"), prompt) } // --- Plan template rendering --- @@ -377,11 +386,11 @@ Do NOT push. Commit only — a reviewer will verify and push. // and writes PLAN.md into the workspace src/ directory. func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) { // Look for template in core/agent/prompts/templates/ - templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml") + templatePath := core.JoinPath(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml") content, err := coreio.Local.Read(templatePath) if err != nil { // Try .yml extension - templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml") + templatePath = core.JoinPath(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml") content, err = coreio.Local.Read(templatePath) if err != nil { return // Template not found, skip silently @@ -390,8 +399,8 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map // Substitute variables ({{variable_name}} → value) for key, value := range variables { - content = strings.ReplaceAll(content, "{{"+key+"}}", value) - content = strings.ReplaceAll(content, "{{ "+key+" }}", value) + content = core.Replace(content, "{{"+key+"}}", value) + content = core.Replace(content, "{{ "+key+" }}", value) } // Parse the YAML to render as markdown @@ -411,42 +420,42 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map } // Render as PLAN.md - var plan strings.Builder - plan.WriteString("# Plan: " + tmpl.Name + "\n\n") + planBuilder := core.NewBuilder() + planBuilder.WriteString("# Plan: " + tmpl.Name + "\n\n") if task != "" { - plan.WriteString("**Task:** " + task + "\n\n") + planBuilder.WriteString("**Task:** " + task + "\n\n") } if tmpl.Description != "" { - plan.WriteString(tmpl.Description + "\n\n") + planBuilder.WriteString(tmpl.Description + "\n\n") } if len(tmpl.Guidelines) > 0 { - plan.WriteString("## Guidelines\n\n") - for _, g := range tmpl.Guidelines { - plan.WriteString("- " + g + "\n") + planBuilder.WriteString("## Guidelines\n\n") + for _, guideline := range tmpl.Guidelines { + planBuilder.WriteString("- " + guideline + "\n") } - plan.WriteString("\n") + planBuilder.WriteString("\n") } - for i, phase := range tmpl.Phases { - plan.WriteString(fmt.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) + for phaseIndex, phase := range tmpl.Phases { + planBuilder.WriteString(core.Sprintf("## Phase %d: %s\n\n", phaseIndex+1, phase.Name)) if phase.Description != "" { - plan.WriteString(phase.Description + "\n\n") + planBuilder.WriteString(phase.Description + "\n\n") } - for _, task := range phase.Tasks { - switch t := task.(type) { + for _, phaseTask := range phase.Tasks { + switch taskValue := phaseTask.(type) { case string: - plan.WriteString("- [ ] " + t + "\n") + planBuilder.WriteString("- [ ] " + taskValue + "\n") case map[string]any: - if name, ok := t["name"].(string); ok { - plan.WriteString("- [ ] " + name + "\n") + if name, ok := taskValue["name"].(string); ok { + planBuilder.WriteString("- [ ] " + name + "\n") } } } - plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n") + planBuilder.WriteString("\n**Commit after completing this phase.**\n\n---\n\n") } - coreio.Local.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String()) + coreio.Local.Write(core.JoinPath(wsDir, "src", "PLAN.md"), planBuilder.String()) } // --- Helpers (unchanged) --- @@ -456,8 +465,8 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i return 0 } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + wikiURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) + req, _ := http.NewRequestWithContext(ctx, "GET", wikiURL, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) @@ -473,7 +482,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i Title string `json:"title"` SubURL string `json:"sub_url"` } - json.NewDecoder(resp.Body).Decode(&pages) + core.JSONUnmarshalString(readBody(resp.Body), &pages) count := 0 for _, page := range pages { @@ -482,7 +491,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i subURL = page.Title } - pageURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) + pageURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) pageReq, _ := http.NewRequestWithContext(ctx, "GET", pageURL, nil) pageReq.Header.Set("Authorization", "token "+s.forgeToken) @@ -498,7 +507,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i var pageData struct { ContentBase64 string `json:"content_base64"` } - json.NewDecoder(pageResp.Body).Decode(&pageData) + core.JSONUnmarshalString(readBody(pageResp.Body), &pageData) pageResp.Body.Close() if pageData.ContentBase64 == "" { @@ -506,14 +515,17 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i } content, _ := base64.StdEncoding.DecodeString(pageData.ContentBase64) - filename := strings.Map(func(r rune) rune { + fileBuilder := core.NewBuilder() + for _, r := range page.Title { if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { - return r + fileBuilder.WriteRune(r) + } else { + fileBuilder.WriteRune('-') } - return '-' - }, page.Title) + ".md" + } + filename := fileBuilder.String() + ".md" - coreio.Local.Write(filepath.Join(wsDir, "src", "kb", filename), string(content)) + coreio.Local.Write(core.JoinPath(wsDir, "src", "kb", filename), string(content)) count++ } @@ -522,17 +534,17 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i func (s *PrepSubsystem) copySpecs(wsDir string) int { specFiles := []string{"AGENT_CONTEXT.md", "TASK_PROTOCOL.md"} - count := 0 + specCount := 0 for _, file := range specFiles { - src := filepath.Join(s.specsPath, file) - if data, err := coreio.Local.Read(src); err == nil { - coreio.Local.Write(filepath.Join(wsDir, "src", "specs", file), data) - count++ + sourcePath := core.JoinPath(s.specsPath, file) + if data, err := coreio.Local.Read(sourcePath); err == nil { + coreio.Local.Write(core.JoinPath(wsDir, "src", "specs", file), data) + specCount++ } } - return count + return specCount } func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) int { @@ -540,14 +552,14 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) return 0 } - body, _ := json.Marshal(map[string]any{ + body := core.JSONMarshalString(map[string]any{ "query": "architecture conventions key interfaces for " + repo, "top_k": 10, "project": repo, "agent_id": "cladius", }) - req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body))) + req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+s.brainKey) @@ -565,26 +577,26 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) var result struct { Memories []map[string]any `json:"memories"` } - json.Unmarshal(respData, &result) + core.JSONUnmarshalString(string(respData), &result) - var content strings.Builder - content.WriteString("# Context — " + repo + "\n\n") - content.WriteString("> Relevant knowledge from OpenBrain.\n\n") + contextBuilder := core.NewBuilder() + contextBuilder.WriteString("# Context — " + repo + "\n\n") + contextBuilder.WriteString("> Relevant knowledge from OpenBrain.\n\n") - for i, mem := range result.Memories { + for memIndex, mem := range result.Memories { memType, _ := mem["type"].(string) memContent, _ := mem["content"].(string) memProject, _ := mem["project"].(string) - score, _ := mem["score"].(float64) - content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent)) + memScore, _ := mem["score"].(float64) + contextBuilder.WriteString(core.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", memIndex+1, memProject, memType, memScore, memContent)) } - coreio.Local.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String()) + coreio.Local.Write(core.JoinPath(wsDir, "src", "CONTEXT.md"), contextBuilder.String()) return len(result.Memories) } func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { - goWorkPath := filepath.Join(s.codePath, "go.work") + goWorkPath := core.JoinPath(s.codePath, "go.work") modulePath := "forge.lthn.ai/core/" + repo workData, err := coreio.Local.Read(goWorkPath) @@ -593,47 +605,47 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { } var consumers []string - for _, line := range strings.Split(workData, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "./") { + for _, line := range core.Split(workData, "\n") { + line = core.Trim(line) + if !core.HasPrefix(line, "./") { continue } - dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./")) - goMod := filepath.Join(dir, "go.mod") + dir := core.JoinPath(s.codePath, core.TrimPrefix(line, "./")) + goMod := core.JoinPath(dir, "go.mod") modData, err := coreio.Local.Read(goMod) if err != nil { continue } - if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) { - consumers = append(consumers, filepath.Base(dir)) + if core.Contains(modData, modulePath) && !core.HasPrefix(modData, "module "+modulePath) { + consumers = append(consumers, core.PathBase(dir)) } } if len(consumers) > 0 { - content := "# Consumers of " + repo + "\n\n" - content += "These modules import `" + modulePath + "`:\n\n" - for _, c := range consumers { - content += "- " + c + "\n" + consumersContent := "# Consumers of " + repo + "\n\n" + consumersContent += "These modules import `" + modulePath + "`:\n\n" + for _, consumer := range consumers { + consumersContent += "- " + consumer + "\n" } - content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) - coreio.Local.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content) + consumersContent += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) + coreio.Local.Write(core.JoinPath(wsDir, "src", "CONSUMERS.md"), consumersContent) } return len(consumers) } func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int { - cmd := exec.Command("git", "log", "--oneline", "-20") - cmd.Dir = repoPath - output, err := cmd.Output() + gitCmd := exec.Command("git", "log", "--oneline", "-20") + gitCmd.Dir = repoPath + output, err := gitCmd.Output() if err != nil { return 0 } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") + lines := core.Split(core.Trim(string(output)), "\n") if len(lines) > 0 && lines[0] != "" { content := "# Recent Changes\n\n```\n" + string(output) + "```\n" - coreio.Local.Write(filepath.Join(wsDir, "src", "RECENT.md"), content) + coreio.Local.Write(core.JoinPath(wsDir, "src", "RECENT.md"), content) } return len(lines) @@ -644,8 +656,8 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu return } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + issueURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + req, _ := http.NewRequestWithContext(ctx, "GET", issueURL, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) @@ -661,13 +673,23 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu Title string `json:"title"` Body string `json:"body"` } - json.NewDecoder(resp.Body).Decode(&issueData) + core.JSONUnmarshalString(readBody(resp.Body), &issueData) - content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title) - content += fmt.Sprintf("**Status:** ready\n") - content += fmt.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) - content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) - content += "## Objective\n\n" + issueData.Body + "\n" + todoContent := core.Sprintf("# TASK: %s\n\n", issueData.Title) + todoContent += "**Status:** ready\n" + todoContent += core.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) + todoContent += core.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) + todoContent += "## Objective\n\n" + issueData.Body + "\n" - coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content) + coreio.Local.Write(core.JoinPath(wsDir, "src", "TODO.md"), todoContent) +} + +// readBody reads an HTTP response body to a string and closes it. +// +// body := readBody(resp.Body) +// core.JSONUnmarshalString(body, &result) +func readBody(body goio.ReadCloser) string { + data, _ := goio.ReadAll(body) + body.Close() + return string(data) } diff --git a/pkg/mcp/agentic/prep_test.go b/pkg/mcp/agentic/prep_test.go index 51f3a60..556e698 100644 --- a/pkg/mcp/agentic/prep_test.go +++ b/pkg/mcp/agentic/prep_test.go @@ -94,3 +94,36 @@ func TestPrepWorkspace_Bad_BadPlanTemplateTraversal(t *testing.T) { t.Fatalf("expected plan template error, got %q", err) } } + +func TestSanitizeRepoPathSegment_Ugly(t *testing.T) { + // Empty value is allowed (returns "", nil) — callers validate presence separately + value, err := sanitizeRepoPathSegment("", "repo", false) + if err != nil { + t.Errorf("expected nil error for empty value, got %v", err) + } + if value != "" { + t.Errorf("expected empty string, got %q", value) + } + + // Null bytes are rejected as invalid characters + _, err = sanitizeRepoPathSegment("repo\x00name", "repo", false) + if err == nil { + t.Error("expected error for null byte in value, got nil") + } + + // Leading whitespace is rejected + _, err = sanitizeRepoPathSegment(" repo", "repo", false) + if err == nil { + t.Error("expected error for leading whitespace, got nil") + } +} + +func TestPrepWorkspace_Ugly(t *testing.T) { + // Empty codePath still validates inputs before hitting the filesystem + s := &PrepSubsystem{codePath: ""} + + _, _, err := s.prepWorkspace(context.Background(), nil, PrepInput{Repo: ""}) + if err == nil { + t.Error("expected error for empty repo with empty codePath, got nil") + } +} diff --git a/pkg/mcp/agentic/queue.go b/pkg/mcp/agentic/queue.go index b8a8972..c102de7 100644 --- a/pkg/mcp/agentic/queue.go +++ b/pkg/mcp/agentic/queue.go @@ -3,14 +3,12 @@ package agentic import ( - "fmt" "os" "os/exec" - "path/filepath" - "strings" "syscall" "time" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" "gopkg.in/yaml.v3" ) @@ -41,21 +39,24 @@ type AgentsConfig struct { } // loadAgentsConfig reads config/agents.yaml from the code path. +// +// agentsConfig := s.loadAgentsConfig() +// limit := agentsConfig.Concurrency["claude"] // 1 func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig { paths := []string{ - filepath.Join(s.codePath, ".core", "agents.yaml"), + core.JoinPath(s.codePath, ".core", "agents.yaml"), } - for _, path := range paths { - data, err := coreio.Local.Read(path) + for _, configPath := range paths { + data, err := coreio.Local.Read(configPath) if err != nil { continue } - var cfg AgentsConfig - if err := yaml.Unmarshal([]byte(data), &cfg); err != nil { + var configuration AgentsConfig + if err := yaml.Unmarshal([]byte(data), &configuration); err != nil { continue } - return &cfg + return &configuration } return &AgentsConfig{ @@ -73,15 +74,15 @@ func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig { // delayForAgent calculates how long to wait before spawning the next task // for a given agent type, based on rate config and time of day. func (s *PrepSubsystem) delayForAgent(agent string) time.Duration { - cfg := s.loadAgentsConfig() - rate, ok := cfg.Rates[agent] + agentsConfig := s.loadAgentsConfig() + rate, ok := agentsConfig.Rates[agent] if !ok || rate.SustainedDelay == 0 { return 0 } - // Parse reset time + // Parse reset time (e.g. "06:00") resetHour, resetMin := 6, 0 - fmt.Sscanf(rate.ResetUTC, "%d:%d", &resetHour, &resetMin) + parseResetTime(rate.ResetUTC, &resetHour, &resetMin) now := time.Now().UTC() resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC) @@ -103,6 +104,9 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration { // listWorkspaceDirs returns all workspace directories, including those // nested one level deep (e.g. workspace/core/go-io-123/). +// +// dirs := s.listWorkspaceDirs() +// // dirs == ["/home/user/.core/workspace/go-io-123", ...] func (s *PrepSubsystem) listWorkspaceDirs() []string { wsRoot := s.workspaceRoot() entries, err := coreio.Local.List(wsRoot) @@ -115,21 +119,21 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string { if !entry.IsDir() { continue } - path := filepath.Join(wsRoot, entry.Name()) + entryPath := core.JoinPath(wsRoot, entry.Name()) // Check if this dir has a status.json (it's a workspace) - if coreio.Local.IsFile(filepath.Join(path, "status.json")) { - dirs = append(dirs, path) + if coreio.Local.IsFile(core.JoinPath(entryPath, "status.json")) { + dirs = append(dirs, entryPath) continue } // Otherwise check one level deeper (org subdirectory) - subEntries, err := coreio.Local.List(path) + subEntries, err := coreio.Local.List(entryPath) if err != nil { continue } for _, sub := range subEntries { if sub.IsDir() { - subPath := filepath.Join(path, sub.Name()) - if coreio.Local.IsFile(filepath.Join(subPath, "status.json")) { + subPath := core.JoinPath(entryPath, sub.Name()) + if coreio.Local.IsFile(core.JoinPath(subPath, "status.json")) { dirs = append(dirs, subPath) } } @@ -146,7 +150,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int { if err != nil || st.Status != "running" { continue } - stBase := strings.SplitN(st.Agent, ":", 2)[0] + stBase := core.SplitN(st.Agent, ":", 2)[0] if stBase != agent { continue } @@ -161,15 +165,18 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int { } // baseAgent strips the model variant (gemini:flash → gemini). +// +// baseAgent("gemini:flash") == "gemini" +// baseAgent("claude") == "claude" func baseAgent(agent string) string { - return strings.SplitN(agent, ":", 2)[0] + return core.SplitN(agent, ":", 2)[0] } // canDispatchAgent checks if we're under the concurrency limit for a specific agent type. func (s *PrepSubsystem) canDispatchAgent(agent string) bool { - cfg := s.loadAgentsConfig() + agentsConfig := s.loadAgentsConfig() base := baseAgent(agent) - limit, ok := cfg.Concurrency[base] + limit, ok := agentsConfig.Concurrency[base] if !ok || limit <= 0 { return true } @@ -205,7 +212,7 @@ func (s *PrepSubsystem) drainQueue() { continue } - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory." command, args, err := agentCommand(st.Agent, prompt) @@ -213,7 +220,7 @@ func (s *PrepSubsystem) drainQueue() { continue } - outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent)) + outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", st.Agent)) outFile, err := os.Create(outputFile) if err != nil { continue @@ -264,3 +271,28 @@ func (s *PrepSubsystem) drainQueue() { return } } + +// parseResetTime parses "HH:MM" into hour and minute integers. +// On invalid input the defaults are unchanged. +// +// parseResetTime("06:30", &h, &m) // h=6, m=30 +func parseResetTime(value string, hour, minute *int) { + parts := core.SplitN(value, ":", 2) + if len(parts) != 2 { + return + } + + type hm struct { + H int `json:"h"` + M int `json:"m"` + } + var target hm + result := core.JSONUnmarshalString( + core.Sprintf(`{"h":%s,"m":%s}`, core.Trim(parts[0]), core.Trim(parts[1])), + &target, + ) + if result.OK { + *hour = target.H + *minute = target.M + } +} diff --git a/pkg/mcp/agentic/resume.go b/pkg/mcp/agentic/resume.go index 0340adb..43ef50d 100644 --- a/pkg/mcp/agentic/resume.go +++ b/pkg/mcp/agentic/resume.go @@ -4,12 +4,11 @@ package agentic import ( "context" - "fmt" "os" "os/exec" - "path/filepath" "syscall" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -45,8 +44,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil) } - wsDir := filepath.Join(s.workspaceRoot(), input.Workspace) - srcDir := filepath.Join(wsDir, "src") + wsDir := core.JoinPath(s.workspaceRoot(), input.Workspace) + srcDir := core.JoinPath(wsDir, "src") // Verify workspace exists if _, err := coreio.Local.List(srcDir); err != nil { @@ -71,8 +70,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu // Write ANSWER.md if answer provided if input.Answer != "" { - answerPath := filepath.Join(srcDir, "ANSWER.md") - content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer) + answerPath := core.JoinPath(srcDir, "ANSWER.md") + content := core.Sprintf("# Answer\n\n%s\n", input.Answer) if err := coreio.Local.Write(answerPath, content); err != nil { return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err) } @@ -95,7 +94,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu } // Spawn agent as detached process (survives parent death) - outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s-run%d.log", agent, st.Runs+1)) + outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s-run%d.log", agent, st.Runs+1)) command, args, err := agentCommand(agent, prompt) if err != nil { diff --git a/pkg/mcp/agentic/scan.go b/pkg/mcp/agentic/scan.go index 4eb741e..a7b9ff6 100644 --- a/pkg/mcp/agentic/scan.go +++ b/pkg/mcp/agentic/scan.go @@ -4,11 +4,9 @@ package agentic import ( "context" - "encoding/json" - "fmt" "net/http" - "strings" + core "dappco.re/go/core" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -81,7 +79,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input seen := make(map[string]bool) var unique []ScanIssue for _, issue := range allIssues { - key := fmt.Sprintf("%s#%d", issue.Repo, issue.Number) + key := core.Sprintf("%s#%d", issue.Repo, issue.Number) if !seen[key] { seen[key] = true unique = append(unique, issue) @@ -100,8 +98,8 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input } func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) { - url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + orgReposURL := core.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org) + req, _ := http.NewRequestWithContext(ctx, "GET", orgReposURL, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) @@ -110,13 +108,14 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, } defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, coreerr.E("listOrgRepos", fmt.Sprintf("HTTP %d listing repos", resp.StatusCode), nil) + resp.Body.Close() + return nil, coreerr.E("listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil) } var repos []struct { Name string `json:"name"` } - json.NewDecoder(resp.Body).Decode(&repos) + core.JSONUnmarshalString(readBody(resp.Body), &repos) var names []string for _, r := range repos { @@ -126,9 +125,9 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, } func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues", + repoIssuesURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues", s.forgeURL, org, repo, label) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req, _ := http.NewRequestWithContext(ctx, "GET", repoIssuesURL, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) @@ -137,7 +136,8 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str } defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, coreerr.E("listRepoIssues", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) + resp.Body.Close() + return nil, coreerr.E("listRepoIssues", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) } var issues []struct { @@ -151,7 +151,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str } `json:"assignee"` HTMLURL string `json:"html_url"` } - json.NewDecoder(resp.Body).Decode(&issues) + core.JSONUnmarshalString(readBody(resp.Body), &issues) var result []ScanIssue for _, issue := range issues { @@ -170,7 +170,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str Title: issue.Title, Labels: labels, Assignee: assignee, - URL: strings.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL, 1), + URL: core.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL), }) } diff --git a/pkg/mcp/agentic/status.go b/pkg/mcp/agentic/status.go index 980c2f0..197b29e 100644 --- a/pkg/mcp/agentic/status.go +++ b/pkg/mcp/agentic/status.go @@ -4,12 +4,10 @@ package agentic import ( "context" - "encoding/json" "os" - "path/filepath" - "strings" "time" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -44,25 +42,29 @@ type WorkspaceStatus struct { PRURL string `json:"pr_url,omitempty"` // pull request URL (after PR created) } +// writeStatus serialises workspace status to status.json. +// +// writeStatus(wsDir, &WorkspaceStatus{Status: "running", Agent: "claude"}) func writeStatus(wsDir string, status *WorkspaceStatus) error { status.UpdatedAt = time.Now() - data, err := json.MarshalIndent(status, "", " ") - if err != nil { - return err - } - return coreio.Local.Write(filepath.Join(wsDir, "status.json"), string(data)) + return coreio.Local.Write(core.JoinPath(wsDir, "status.json"), core.JSONMarshalString(status)) } +// readStatus deserialises workspace status from status.json. +// +// st, err := readStatus(wsDir) +// // st.Status == "running", st.Agent == "claude" func readStatus(wsDir string) (*WorkspaceStatus, error) { - data, err := coreio.Local.Read(filepath.Join(wsDir, "status.json")) + data, err := coreio.Local.Read(core.JoinPath(wsDir, "status.json")) if err != nil { return nil, err } - var s WorkspaceStatus - if err := json.Unmarshal([]byte(data), &s); err != nil { - return nil, err + var workspaceStatus WorkspaceStatus + result := core.JSONUnmarshalString(data, &workspaceStatus) + if !result.OK { + return nil, coreerr.E("readStatus", "failed to parse status.json", nil) } - return &s, nil + return &workspaceStatus, nil } // --- agentic_status tool --- @@ -103,60 +105,60 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu var workspaces []WorkspaceInfo for _, wsDir := range wsDirs { - name := filepath.Base(wsDir) + workspaceName := core.PathBase(wsDir) // Filter by specific workspace if requested - if input.Workspace != "" && name != input.Workspace { + if input.Workspace != "" && workspaceName != input.Workspace { continue } - info := WorkspaceInfo{Name: name} + info := WorkspaceInfo{Name: workspaceName} // Try reading status.json - st, err := readStatus(wsDir) + statusRecord, err := readStatus(wsDir) if err != nil { // Legacy workspace (no status.json) — check for log file - logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) + logFiles := core.PathGlob(core.JoinPath(wsDir, "agent-*.log")) if len(logFiles) > 0 { info.Status = "completed" } else { info.Status = "unknown" } - if fi, err := os.Stat(wsDir); err == nil { - info.Age = time.Since(fi.ModTime()).Truncate(time.Minute).String() + if fileInfo, statErr := os.Stat(wsDir); statErr == nil { + info.Age = time.Since(fileInfo.ModTime()).Truncate(time.Minute).String() } workspaces = append(workspaces, info) continue } - info.Status = st.Status - info.Agent = st.Agent - info.Repo = st.Repo - info.Task = st.Task - info.Runs = st.Runs - info.Age = time.Since(st.StartedAt).Truncate(time.Minute).String() + info.Status = statusRecord.Status + info.Agent = statusRecord.Agent + info.Repo = statusRecord.Repo + info.Task = statusRecord.Task + info.Runs = statusRecord.Runs + info.Age = time.Since(statusRecord.StartedAt).Truncate(time.Minute).String() // If status is "running", check if PID is still alive - if st.Status == "running" && st.PID > 0 { - proc, err := os.FindProcess(st.PID) + if statusRecord.Status == "running" && statusRecord.PID > 0 { + proc, err := os.FindProcess(statusRecord.PID) if err != nil || proc.Signal(nil) != nil { // Process died — check for BLOCKED.md - blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md") + blockedPath := core.JoinPath(wsDir, "src", "BLOCKED.md") if data, err := coreio.Local.Read(blockedPath); err == nil { info.Status = "blocked" - info.Question = strings.TrimSpace(data) - st.Status = "blocked" - st.Question = info.Question + info.Question = core.Trim(data) + statusRecord.Status = "blocked" + statusRecord.Question = info.Question } else { info.Status = "completed" - st.Status = "completed" + statusRecord.Status = "completed" } - writeStatus(wsDir, st) + writeStatus(wsDir, statusRecord) } } - if st.Status == "blocked" { - info.Question = st.Question + if statusRecord.Status == "blocked" { + info.Question = statusRecord.Question } workspaces = append(workspaces, info) diff --git a/pkg/mcp/brain/direct.go b/pkg/mcp/brain/direct.go index 3a7115f..8a89290 100644 --- a/pkg/mcp/brain/direct.go +++ b/pkg/mcp/brain/direct.go @@ -5,14 +5,11 @@ package brain import ( "bytes" "context" - "encoding/json" - "fmt" goio "io" "net/http" - "os" - "strings" "time" + core "dappco.re/go/core" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -44,16 +41,20 @@ func (s *DirectSubsystem) OnChannel(fn func(ctx context.Context, channel string, // NewDirect creates a brain subsystem that calls the OpenBrain API directly. // Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back // to ~/.claude/brain.key for the API key. +// +// sub := brain.NewDirect() +// svc, _ := mcp.New(mcp.Options{Subsystems: []mcp.Subsystem{sub}}) func NewDirect() *DirectSubsystem { - apiURL := os.Getenv("CORE_BRAIN_URL") + apiURL := core.Env("CORE_BRAIN_URL") if apiURL == "" { apiURL = "https://api.lthn.sh" } - apiKey := os.Getenv("CORE_BRAIN_KEY") + apiKey := core.Env("CORE_BRAIN_KEY") if apiKey == "" { - if data, err := coreio.Local.Read(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil { - apiKey = strings.TrimSpace(data) + keyPath := core.JoinPath(core.Env("HOME"), ".claude", "brain.key") + if data, err := coreio.Local.Read(keyPath); err == nil { + apiKey = core.Trim(data) } } @@ -95,11 +96,7 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body var reqBody goio.Reader if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, coreerr.E("brain.apiCall", "marshal request", err) - } - reqBody = bytes.NewReader(data) + reqBody = bytes.NewReader([]byte(core.JSONMarshalString(body))) } req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody) @@ -126,8 +123,9 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body } var result map[string]any - if err := json.Unmarshal(respData, &result); err != nil { - return nil, coreerr.E("brain.apiCall", "parse response", err) + r := core.JSONUnmarshalString(string(respData), &result) + if !r.OK { + return nil, coreerr.E("brain.apiCall", "parse response", nil) } return result, nil @@ -186,11 +184,11 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in for _, m := range mems { if mm, ok := m.(map[string]any); ok { mem := Memory{ - Content: fmt.Sprintf("%v", mm["content"]), - Type: fmt.Sprintf("%v", mm["type"]), - Project: fmt.Sprintf("%v", mm["project"]), - AgentID: fmt.Sprintf("%v", mm["agent_id"]), - CreatedAt: fmt.Sprintf("%v", mm["created_at"]), + Content: core.Sprintf("%v", mm["content"]), + Type: core.Sprintf("%v", mm["type"]), + Project: core.Sprintf("%v", mm["project"]), + AgentID: core.Sprintf("%v", mm["agent_id"]), + CreatedAt: core.Sprintf("%v", mm["created_at"]), } if id, ok := mm["id"].(string); ok { mem.ID = id diff --git a/pkg/mcp/ide/bridge.go b/pkg/mcp/ide/bridge.go index 633d934..8f58c26 100644 --- a/pkg/mcp/ide/bridge.go +++ b/pkg/mcp/ide/bridge.go @@ -24,8 +24,8 @@ type BridgeMessage struct { // Bridge maintains a WebSocket connection to the Laravel core-agentic // backend and forwards responses to a local ws.Hub. type Bridge struct { - cfg Config - hub *ws.Hub + config Config + hub *ws.Hub conn *websocket.Conn mu sync.Mutex @@ -35,8 +35,10 @@ type Bridge struct { // NewBridge creates a bridge that will connect to the Laravel backend and // forward incoming messages to the provided ws.Hub channels. -func NewBridge(hub *ws.Hub, cfg Config) *Bridge { - return &Bridge{cfg: cfg, hub: hub} +// +// bridge := ide.NewBridge(hub, ide.DefaultConfig()) +func NewBridge(hub *ws.Hub, configuration Config) *Bridge { + return &Bridge{config: configuration, hub: hub} } // Start begins the connection loop in a background goroutine. @@ -81,7 +83,7 @@ func (b *Bridge) Send(msg BridgeMessage) error { // connectLoop reconnects to Laravel with exponential backoff. func (b *Bridge) connectLoop(ctx context.Context) { - delay := b.cfg.ReconnectInterval + delay := b.config.ReconnectInterval for { select { case <-ctx.Done(): @@ -96,12 +98,12 @@ func (b *Bridge) connectLoop(ctx context.Context) { return case <-time.After(delay): } - delay = min(delay*2, b.cfg.MaxReconnectInterval) + delay = min(delay*2, b.config.MaxReconnectInterval) continue } // Reset backoff on successful connection - delay = b.cfg.ReconnectInterval + delay = b.config.ReconnectInterval b.readLoop(ctx) } } @@ -112,12 +114,12 @@ func (b *Bridge) dial(ctx context.Context) error { } var header http.Header - if b.cfg.Token != "" { + if b.config.Token != "" { header = http.Header{} - header.Set("Authorization", "Bearer "+b.cfg.Token) + header.Set("Authorization", "Bearer "+b.config.Token) } - conn, _, err := dialer.DialContext(ctx, b.cfg.LaravelWSURL, header) + conn, _, err := dialer.DialContext(ctx, b.config.LaravelWSURL, header) if err != nil { return err } @@ -127,7 +129,7 @@ func (b *Bridge) dial(ctx context.Context) error { b.connected = true b.mu.Unlock() - coreerr.Info("ide bridge: connected", "url", b.cfg.LaravelWSURL) + coreerr.Info("ide bridge: connected", "url", b.config.LaravelWSURL) return nil } diff --git a/pkg/mcp/ide/ide.go b/pkg/mcp/ide/ide.go index 64c4274..e49155d 100644 --- a/pkg/mcp/ide/ide.go +++ b/pkg/mcp/ide/ide.go @@ -14,23 +14,25 @@ var errBridgeNotAvailable = coreerr.E("ide", "bridge not available", nil) // Subsystem implements mcp.Subsystem and mcp.SubsystemWithShutdown for the IDE. type Subsystem struct { - cfg Config + config Config bridge *Bridge hub *ws.Hub } // New creates an IDE subsystem. The ws.Hub is used for real-time forwarding; // pass nil if headless (tools still work but real-time streaming is disabled). +// +// sub := ide.New(hub, ide.WithToken("sk-abc")) func New(hub *ws.Hub, opts ...Option) *Subsystem { - cfg := DefaultConfig() + configuration := DefaultConfig() for _, opt := range opts { - opt(&cfg) + opt(&configuration) } var bridge *Bridge if hub != nil { - bridge = NewBridge(hub, cfg) + bridge = NewBridge(hub, configuration) } - return &Subsystem{cfg: cfg, bridge: bridge, hub: hub} + return &Subsystem{config: configuration, bridge: bridge, hub: hub} } // Name implements mcp.Subsystem. diff --git a/pkg/mcp/ide/tools_test.go b/pkg/mcp/ide/tools_test.go index 21a01fa..c9bf5bd 100644 --- a/pkg/mcp/ide/tools_test.go +++ b/pkg/mcp/ide/tools_test.go @@ -716,17 +716,17 @@ func TestSubsystem_Good_WithOptions(t *testing.T) { WithToken("secret-123"), ) - if sub.cfg.LaravelWSURL != "ws://custom:1234/ws" { - t.Errorf("expected custom URL, got %q", sub.cfg.LaravelWSURL) + if sub.config.LaravelWSURL != "ws://custom:1234/ws" { + t.Errorf("expected custom URL, got %q", sub.config.LaravelWSURL) } - if sub.cfg.WorkspaceRoot != "/tmp/test" { - t.Errorf("expected workspace '/tmp/test', got %q", sub.cfg.WorkspaceRoot) + if sub.config.WorkspaceRoot != "/tmp/test" { + t.Errorf("expected workspace '/tmp/test', got %q", sub.config.WorkspaceRoot) } - if sub.cfg.ReconnectInterval != 5*time.Second { - t.Errorf("expected 5s reconnect interval, got %v", sub.cfg.ReconnectInterval) + if sub.config.ReconnectInterval != 5*time.Second { + t.Errorf("expected 5s reconnect interval, got %v", sub.config.ReconnectInterval) } - if sub.cfg.Token != "secret-123" { - t.Errorf("expected token 'secret-123', got %q", sub.cfg.Token) + if sub.config.Token != "secret-123" { + t.Errorf("expected token 'secret-123', got %q", sub.config.Token) } } diff --git a/pkg/mcp/transport_http.go b/pkg/mcp/transport_http.go index cd25417..821124e 100644 --- a/pkg/mcp/transport_http.go +++ b/pkg/mcp/transport_http.go @@ -7,10 +7,9 @@ import ( "crypto/subtle" "net" "net/http" - "os" - "strings" "time" + core "dappco.re/go/core" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -37,7 +36,7 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error { addr = DefaultHTTPAddr } - authToken := os.Getenv("MCP_AUTH_TOKEN") + authToken := core.Env("MCP_AUTH_TOKEN") handler := mcp.NewStreamableHTTPHandler( func(r *http.Request) *mcp.Server { @@ -85,19 +84,19 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error { // If token is empty, requests are rejected. func withAuth(token string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.TrimSpace(token) == "" { + if core.Trim(token) == "" { w.Header().Set("WWW-Authenticate", `Bearer`) http.Error(w, `{"error":"authentication not configured"}`, http.StatusUnauthorized) return } auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") { + if !core.HasPrefix(auth, "Bearer ") { http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) return } - provided := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + provided := core.Trim(core.TrimPrefix(auth, "Bearer ")) if len(provided) == 0 { http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) return diff --git a/pkg/mcp/transport_tcp.go b/pkg/mcp/transport_tcp.go index 860bd18..ddb3788 100644 --- a/pkg/mcp/transport_tcp.go +++ b/pkg/mcp/transport_tcp.go @@ -3,12 +3,12 @@ package mcp import ( "bufio" "context" - "fmt" goio "io" "net" "os" "sync" + core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -29,7 +29,7 @@ var diagWriter goio.Writer = os.Stderr func diagPrintf(format string, args ...any) { diagMu.Lock() defer diagMu.Unlock() - fmt.Fprintf(diagWriter, format, args...) + diagWriter.Write([]byte(core.Sprintf(format, args...))) //nolint:errcheck } // setDiagWriter swaps the diagnostic writer and returns the previous one. diff --git a/pkg/mcp/transport_tcp_test.go b/pkg/mcp/transport_tcp_test.go index 109a617..4c5e91b 100644 --- a/pkg/mcp/transport_tcp_test.go +++ b/pkg/mcp/transport_tcp_test.go @@ -10,147 +10,137 @@ import ( "time" ) -func TestNewTCPTransport_Defaults(t *testing.T) { - // Test that empty string gets replaced with default address constant - // Note: We can't actually bind to 9100 as it may be in use, - // so we verify the address is set correctly before Listen is called +func TestTransportTcp_NewTCPTransport_Good(t *testing.T) { + // Default constant is correctly set if DefaultTCPAddr != "127.0.0.1:9100" { - t.Errorf("Expected default constant 127.0.0.1:9100, got %s", DefaultTCPAddr) + t.Errorf("expected default constant 127.0.0.1:9100, got %s", DefaultTCPAddr) } - // Test with a dynamic port to verify transport creation works + // Create transport with dynamic port tr, err := NewTCPTransport("127.0.0.1:0") if err != nil { - t.Fatalf("Failed to create transport with dynamic port: %v", err) + t.Fatalf("failed to create transport with dynamic port: %v", err) } defer tr.listener.Close() - // Verify we got a valid address if tr.addr != "127.0.0.1:0" { - t.Errorf("Expected address to be set, got %s", tr.addr) + t.Errorf("expected address to be set, got %s", tr.addr) } } -func TestNewTCPTransport_Warning(t *testing.T) { - // Capture warning output via setDiagWriter (mutex-protected, no race). +func TestTransportTcp_NewTCPTransport_Bad(t *testing.T) { + // Binding to an already-in-use port returns an error + tr, err := NewTCPTransport("127.0.0.1:0") + if err != nil { + t.Fatalf("first bind failed unexpectedly: %v", err) + } + defer tr.listener.Close() + + addr := tr.listener.Addr().String() + _, err = NewTCPTransport(addr) + if err == nil { + t.Error("expected error when binding to already-in-use port, got nil") + } +} + +func TestTransportTcp_NewTCPTransport_Ugly(t *testing.T) { + // Empty host defaults to 127.0.0.1 — never binds to 0.0.0.0 var buf bytes.Buffer old := setDiagWriter(&buf) defer setDiagWriter(old) - // Trigger warning — use port 0 (OS assigns free port) - tr, err := NewTCPTransport("0.0.0.0:0") + tr, err := NewTCPTransport(":0") if err != nil { - t.Fatalf("Failed to create transport: %v", err) + t.Fatalf("failed to create transport with empty host: %v", err) } defer tr.listener.Close() - output := buf.String() - if !strings.Contains(output, "WARNING") { - t.Error("Expected warning for binding to 0.0.0.0, but didn't find it in stderr") + // Should NOT have emitted a warning for 0.0.0.0 + if strings.Contains(buf.String(), "WARNING") { + t.Error("unexpected warning for :0 (should default to 127.0.0.1, not 0.0.0.0)") + } + + // The bound address must be on 127.0.0.1, not 0.0.0.0 + host, _, _ := net.SplitHostPort(tr.listener.Addr().String()) + if host != "127.0.0.1" { + t.Errorf("expected 127.0.0.1, got %s", host) } } -func TestServeTCP_Connection(t *testing.T) { +func TestTransportTcp_ServeTCP_Good(t *testing.T) { s, err := New(Options{}) if err != nil { - t.Fatalf("Failed to create service: %v", err) + t.Fatalf("failed to create service: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Use a random port for testing to avoid collisions - addr := "127.0.0.1:0" - - // Create transport first to get the actual address if we use :0 - tr, err := NewTCPTransport(addr) + tr, err := NewTCPTransport("127.0.0.1:0") if err != nil { - t.Fatalf("Failed to create transport: %v", err) - } - actualAddr := tr.listener.Addr().String() - tr.listener.Close() // Close it so ServeTCP can re-open it or use the same address - - // Start server in background - errCh := make(chan error, 1) - go func() { - errCh <- s.ServeTCP(ctx, actualAddr) - }() - - // Give it a moment to start - time.Sleep(100 * time.Millisecond) - - // Connect to the server - conn, err := net.Dial("tcp", actualAddr) - if err != nil { - t.Fatalf("Failed to connect to server: %v", err) - } - defer conn.Close() - - // Verify we can write to it - _, err = conn.Write([]byte("{}\n")) - if err != nil { - t.Errorf("Failed to write to connection: %v", err) - } - - // Shutdown server - cancel() - err = <-errCh - if err != nil { - t.Errorf("ServeTCP returned error: %v", err) - } -} - -func TestRun_TCPTrigger(t *testing.T) { - s, err := New(Options{}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Set MCP_ADDR to empty to trigger default TCP - os.Setenv("MCP_ADDR", "") - defer os.Unsetenv("MCP_ADDR") - - // We use a random port for testing, but Run will try to use 127.0.0.1:9100 by default if we don't override. - // Since 9100 might be in use, we'll set MCP_ADDR to use :0 (random port) - os.Setenv("MCP_ADDR", "127.0.0.1:0") - - errCh := make(chan error, 1) - go func() { - errCh <- s.Run(ctx) - }() - - // Give it a moment to start - time.Sleep(100 * time.Millisecond) - - // Since we can't easily get the actual port used by Run (it's internal), - // we just verify it didn't immediately fail. - select { - case err := <-errCh: - t.Fatalf("Run failed immediately: %v", err) - default: - // still running, which is good - } - - cancel() - _ = <-errCh -} - -func TestServeTCP_MultipleConnections(t *testing.T) { - s, err := New(Options{}) - if err != nil { - t.Fatalf("Failed to create service: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - addr := "127.0.0.1:0" - tr, err := NewTCPTransport(addr) - if err != nil { - t.Fatalf("Failed to create transport: %v", err) + t.Fatalf("failed to create transport: %v", err) + } + actualAddr := tr.listener.Addr().String() + tr.listener.Close() + + errCh := make(chan error, 1) + go func() { + errCh <- s.ServeTCP(ctx, actualAddr) + }() + + time.Sleep(100 * time.Millisecond) + + conn, err := net.Dial("tcp", actualAddr) + if err != nil { + t.Fatalf("failed to connect to server: %v", err) + } + defer conn.Close() + + _, err = conn.Write([]byte("{}\n")) + if err != nil { + t.Errorf("failed to write to connection: %v", err) + } + + cancel() + if err = <-errCh; err != nil { + t.Errorf("ServeTCP returned error: %v", err) + } +} + +func TestTransportTcp_ServeTCP_Bad(t *testing.T) { + // ServeTCP with an already-in-use address returns an error + tr, err := NewTCPTransport("127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create transport: %v", err) + } + defer tr.listener.Close() + addr := tr.listener.Addr().String() + + s, err := New(Options{}) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + ctx := context.Background() + err = s.ServeTCP(ctx, addr) + if err == nil { + t.Error("expected error when binding to already-in-use port, got nil") + } +} + +func TestTransportTcp_ServeTCP_Ugly(t *testing.T) { + // Multiple simultaneous clients can connect and write without error + s, err := New(Options{}) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tr, err := NewTCPTransport("127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create transport: %v", err) } actualAddr := tr.listener.Addr().String() tr.listener.Close() @@ -162,23 +152,68 @@ func TestServeTCP_MultipleConnections(t *testing.T) { time.Sleep(100 * time.Millisecond) - // Connect multiple clients const numClients = 3 for i := range numClients { conn, err := net.Dial("tcp", actualAddr) if err != nil { - t.Fatalf("Client %d failed to connect: %v", i, err) + t.Fatalf("client %d failed to connect: %v", i, err) } defer conn.Close() _, err = conn.Write([]byte("{}\n")) if err != nil { - t.Errorf("Client %d failed to write: %v", i, err) + t.Errorf("client %d failed to write: %v", i, err) } } cancel() - err = <-errCh - if err != nil { + if err = <-errCh; err != nil { t.Errorf("ServeTCP returned error: %v", err) } } + +func TestTransportTcp_Run_Good(t *testing.T) { + s, err := New(Options{}) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + os.Setenv("MCP_ADDR", "127.0.0.1:0") + defer os.Unsetenv("MCP_ADDR") + + errCh := make(chan error, 1) + go func() { + errCh <- s.Run(ctx) + }() + + time.Sleep(100 * time.Millisecond) + + select { + case err := <-errCh: + t.Fatalf("Run failed immediately: %v", err) + default: + // still running, which is good + } + + cancel() + _ = <-errCh +} + +func TestTransportTcp_Warning_Ugly(t *testing.T) { + // Binding to 0.0.0.0 emits a security warning + var buf bytes.Buffer + old := setDiagWriter(&buf) + defer setDiagWriter(old) + + tr, err := NewTCPTransport("0.0.0.0:0") + if err != nil { + t.Fatalf("failed to create transport: %v", err) + } + defer tr.listener.Close() + + if !strings.Contains(buf.String(), "WARNING") { + t.Error("expected security warning for 0.0.0.0 binding, got none") + } +}