From 84958c7ef3ceddfceab3a27b71f09baec3d25202 Mon Sep 17 00:00:00 2001 From: Alexandru Macocian Date: Mon, 15 Jun 2026 19:03:56 +0200 Subject: [PATCH] Support for local agents --- VERSION | 2 +- cmd/sherlock/main.go | 30 +++++++++ config.example.toml | 13 ++++ docs/agents.md | 32 ++++++++-- internal/agent/agent.go | 16 +++-- internal/agent/backend.go | 78 ++++++++++++++++++++++++ internal/agent/claude.go | 25 ++------ internal/agent/copilot.go | 24 ++------ internal/agent/custom.go | 56 +++++++++++++++++ internal/agent/custom_test.go | 107 +++++++++++++++++++++++++++++++++ internal/config/config.go | 19 ++++++ internal/config/config_test.go | 44 ++++++++++++++ 12 files changed, 395 insertions(+), 51 deletions(-) create mode 100644 internal/agent/backend.go create mode 100644 internal/agent/custom.go create mode 100644 internal/agent/custom_test.go diff --git a/VERSION b/VERSION index 1180819..699c6c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.7 +0.1.8 diff --git a/cmd/sherlock/main.go b/cmd/sherlock/main.go index 7282cac..d11c21f 100644 --- a/cmd/sherlock/main.go +++ b/cmd/sherlock/main.go @@ -18,6 +18,7 @@ import ( "time" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent" + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/config" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp" ) @@ -58,6 +59,13 @@ func main() { // `go install` even if the operator never touched their shell rc. agent.EnsurePathContainsSiblings() + // Register operator-defined custom agents from config.toml so they + // dispatch just like the built-ins below. + if err := registerCustomAgents(); err != nil { + fmt.Fprintln(os.Stderr, "sherlock:", err) + os.Exit(exitUsage) + } + switch sub { case "status": runStatus(store, rest) @@ -239,6 +247,28 @@ func knownMCPs() (mcp.Servers, error) { return out, nil } +// registerCustomAgents loads the operator config (if any) and registers +// each `[agents.]` entry as a dispatchable agent. A missing config +// file is not an error — sherlock simply offers only the built-ins. A +// malformed agent entry (unknown backend, name collision) is fatal, in +// keeping with the config package's "refuse to start rather than +// misbehave" philosophy. +func registerCustomAgents() error { + cfg, err := config.Load() + if err != nil { + if errors.Is(err, config.ErrNotFound) { + return nil + } + return err + } + for name, a := range cfg.Agents { + if err := agent.RegisterCustom(name, a.Command, a.Backend); err != nil { + return err + } + } + return nil +} + func humanizeUntil(t time.Time) string { d := time.Until(t).Round(time.Second) if d < 0 { diff --git a/config.example.toml b/config.example.toml index d26a659..321b377 100644 --- a/config.example.toml +++ b/config.example.toml @@ -46,3 +46,16 @@ base_url = "https://terminal.example.com" [services.searxng] provider = "sherlock-cli" base_url = "https://search.example.com" + +# ── Custom agents ──────────────────────────────────────────────────── +# A [agents.] block exposes an operator-owned wrapper command as +# `sherlock `, reusing a built-in backend's MCP/-env conventions. +# `backend` must be "copilot" or "claude" — it tells sherlock how to +# format the MCP config for the wrapped CLI. `command` is the binary to +# exec and defaults to when omitted. +# +# Example: surface a `copilot-local` wrapper (Copilot CLI pointed at a +# self-hosted model via BYOK) as `sherlock copilot-local`. +# [agents.copilot-local] +# command = "copilot-local" +# backend = "copilot" diff --git a/docs/agents.md b/docs/agents.md index 2fde687..7bc2688 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -23,7 +23,31 @@ behavior. ## Changing agents -Adding an agent is a code change under `internal/agent/`. Implement the small -agent interface, register it in `init`, and use `internal/mcp` for config -rendering. The exact API lives in `internal/agent/agent.go`; shared spawn -helpers live in `internal/agent/exec.go`. +Adding a built-in agent is a code change under `internal/agent/`. Implement +the small agent interface, register it in `init`, and use `internal/mcp` +for config rendering. The exact API lives in `internal/agent/agent.go`; +shared spawn helpers live in `internal/agent/exec.go`, and the per-CLI +MCP/-env conventions ("backends") live in `internal/agent/backend.go`. + +## Custom agents (config) + +Operators can register their own agents from `config.toml` without +touching code — handy for wrapper commands such as a `copilot-local` +script that points the Copilot CLI at a self-hosted model: + +```toml +[agents.copilot-local] +command = "copilot-local" # binary to exec; defaults to the name if omitted +backend = "copilot" # which CLI's MCP/-env conventions to use +``` + +`backend` must be one of the built-in backends (`copilot` or `claude`); +it tells sherlock how to format the MCP config (`--additional-mcp-config +@` vs `--mcp-config `) and what env to scrub. The rendered +MCP file is named after the agent (`.mcp.json`). + +`sherlock copilot-local [args...]` then dispatches exactly like a +built-in. A custom agent whose name collides with a built-in agent, or +that names an unknown backend, is a hard startup error. (Names matching a +reserved subcommand — `status`, `logout`, `run`, `update`, `version` — +are shadowed by that subcommand; reach them via `sherlock run `.) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e3f2adb..d4f7a04 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1,9 +1,15 @@ // Package agent dispatches `sherlock ` invocations to a small -// set of supported CLIs (Copilot, Claude Code, …). Each agent is a -// concrete type in its own file that implements the Agent interface; -// they register themselves at init time. Adding a new agent is a -// matter of dropping a new file in this package — there is no TOML -// schema and no user-facing extensibility surface, by design. +// set of supported CLIs (Copilot, Claude Code, …). Each built-in agent +// is a concrete type in its own file that implements the Agent +// interface; they register themselves at init time. Adding a new +// built-in is a matter of dropping a new file in this package. +// +// Operators can also declare custom agents in config.toml's +// `[agents.]` table — a wrapper command plus a backend type +// ("copilot" or "claude") that selects the MCP-config and env +// conventions. Those are added at runtime via RegisterCustom. The +// per-CLI quirks shared by built-in and custom agents live in +// backend.go. // // The reusable bits live in this package too (exec.go for env/exec // helpers; the mcp sibling package for MCP-config rendering). Agent diff --git a/internal/agent/backend.go b/internal/agent/backend.go new file mode 100644 index 0000000..9d641f5 --- /dev/null +++ b/internal/agent/backend.go @@ -0,0 +1,78 @@ +package agent + +import ( + "fmt" + "sort" + + "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp" +) + +// backend captures the per-CLI quirks shared by every agent that speaks +// to a given coding-assistant CLI: how the rendered MCP-config file is +// passed on the command line, and which env vars must be scrubbed from +// the child process. +// +// Built-in agents (copilot, claude) and operator-defined custom agents +// from config.toml both spawn through a backend, so a `copilot-local` +// wrapper formats its MCP config exactly like the real `copilot`. +type backend struct { + // mcpArgs returns the CLI flags that point the agent at the rendered + // MCP-config file at path. + mcpArgs func(path string) []string + // forbidEnv lists env vars stripped from the child (e.g. a personal + // ANTHROPIC_API_KEY that must not override broker-managed behavior). + forbidEnv []string +} + +// backends is the set of known backend types. The keys are the values +// operators put in `[agents.].backend`. +var backends = map[string]backend{ + "copilot": { + // The `@` prefix tells Copilot to treat the argument as a file + // path rather than inline JSON. + mcpArgs: func(p string) []string { return []string{"--additional-mcp-config", "@" + p} }, + }, + "claude": { + mcpArgs: func(p string) []string { return []string{"--mcp-config", p} }, + forbidEnv: []string{"ANTHROPIC_API_KEY"}, + }, +} + +// backendNames returns the known backend type names, sorted, for error +// messages. +func backendNames() []string { + out := make([]string, 0, len(backends)) + for n := range backends { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// spawn is the shared spawn path used by both built-in and custom +// agents. name selects the rendered MCP-config file name; command is the +// binary to exec. On success it does not return (the process is +// replaced); any returned error is a setup failure. +func (b backend) spawn(name, command string, ctx Context, args []string) error { + bin, err := LookPath(command) + if err != nil { + return err + } + mcpPath, err := mcp.Render(name, ctx.Servers) + if err != nil { + return err + } + argv := append([]string{bin}, b.mcpArgs(mcpPath)...) + argv = append(argv, args...) + return DefaultExecer.Exec(bin, argv, BuildEnv(b.forbidEnv, nil)) +} + +// resolveBackend returns the backend for a type name, with a friendly +// error listing the valid choices when it is unknown. +func resolveBackend(name string) (backend, error) { + b, ok := backends[name] + if !ok { + return backend{}, fmt.Errorf("unknown backend %q (want one of: %v)", name, backendNames()) + } + return b, nil +} diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 52c338d..0888269 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -1,9 +1,5 @@ package agent -import ( - "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp" -) - func init() { Register(&claude{}) } // claude integrates Anthropic's Claude Code CLI (npm package @@ -14,27 +10,14 @@ func init() { Register(&claude{}) } // We also forbid ANTHROPIC_API_KEY in the child env so an // inadvertently-set personal key can't override the broker-managed // session. (Phase 2 will surface a proper Anthropic auth grant.) +// +// The per-CLI quirks (flag shape, env scrubbing) live in the shared +// "claude" backend; this type is just the built-in registration. type claude struct{} func (claude) Name() string { return "claude" } func (claude) Description() string { return "Anthropic Claude Code CLI" } func (c claude) Spawn(ctx Context, args []string) error { - bin, err := LookPath("claude") - if err != nil { - return err - } - mcpPath, err := mcp.Render(c.Name(), ctx.Servers) - if err != nil { - return err - } - argv := c.buildArgv(bin, mcpPath, args) - env := BuildEnv([]string{"ANTHROPIC_API_KEY"}, nil) - return DefaultExecer.Exec(bin, argv, env) -} - -func (claude) buildArgv(bin, mcpPath string, userArgs []string) []string { - argv := []string{bin, "--mcp-config", mcpPath} - argv = append(argv, userArgs...) - return argv + return backends["claude"].spawn(c.Name(), "claude", ctx, args) } diff --git a/internal/agent/copilot.go b/internal/agent/copilot.go index 57c5894..62fe27a 100644 --- a/internal/agent/copilot.go +++ b/internal/agent/copilot.go @@ -1,9 +1,5 @@ package agent -import ( - "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp" -) - func init() { Register(&copilot{}) } // copilot integrates the GitHub Copilot CLI (npm package `@github/copilot` @@ -17,26 +13,14 @@ func init() { Register(&copilot{}) } // any `--additional-mcp-config` files on top. The merge is additive, // so sherlock-injected MCPs coexist with whatever the operator has // configured globally. +// +// The per-CLI quirks (flag shape, env scrubbing) live in the shared +// "copilot" backend; this type is just the built-in registration. type copilot struct{} func (copilot) Name() string { return "copilot" } func (copilot) Description() string { return "GitHub Copilot CLI" } func (c copilot) Spawn(ctx Context, args []string) error { - bin, err := LookPath("copilot") - if err != nil { - return err - } - mcpPath, err := mcp.Render(c.Name(), ctx.Servers) - if err != nil { - return err - } - argv := c.buildArgv(bin, mcpPath, args) - return DefaultExecer.Exec(bin, argv, BuildEnv(nil, nil)) -} - -func (copilot) buildArgv(bin, mcpPath string, userArgs []string) []string { - argv := []string{bin, "--additional-mcp-config", "@" + mcpPath} - argv = append(argv, userArgs...) - return argv + return backends["copilot"].spawn(c.Name(), "copilot", ctx, args) } diff --git a/internal/agent/custom.go b/internal/agent/custom.go new file mode 100644 index 0000000..425d411 --- /dev/null +++ b/internal/agent/custom.go @@ -0,0 +1,56 @@ +package agent + +import "fmt" + +// custom is an operator-defined agent declared in config.toml's +// `[agents.]` table. It wraps an arbitrary command — typically a +// shell wrapper such as `copilot-local` that points a CLI at a +// self-hosted model — and reuses a built-in backend's MCP-config and +// env conventions so MCPs are formatted exactly as the underlying CLI +// expects. +type custom struct { + name string + desc string + command string + backend backend +} + +func (c custom) Name() string { return c.name } +func (c custom) Description() string { return c.desc } + +func (c custom) Spawn(ctx Context, args []string) error { + return c.backend.spawn(c.name, c.command, ctx, args) +} + +// RegisterCustom adds an operator-defined agent to the registry. +// +// - name is the subcommand: `sherlock `. +// - command is the binary to exec; it defaults to name when empty. +// - backendName selects the MCP/-env conventions ("copilot" or +// "claude"). +// +// Unlike Register, it returns an error (rather than panicking) on a bad +// backend or a name that collides with an already-registered agent, +// since the input comes from operator config rather than program code. +func RegisterCustom(name, command, backendName string) error { + if name == "" { + return fmt.Errorf("agent: custom agent needs a name") + } + b, err := resolveBackend(backendName) + if err != nil { + return fmt.Errorf("agent: custom agent %q: %w", name, err) + } + if command == "" { + command = name + } + if _, dup := registry[name]; dup { + return fmt.Errorf("agent: custom agent %q conflicts with an existing agent", name) + } + registry[name] = custom{ + name: name, + desc: fmt.Sprintf("custom %s agent (%s)", backendName, command), + command: command, + backend: b, + } + return nil +} diff --git a/internal/agent/custom_test.go b/internal/agent/custom_test.go new file mode 100644 index 0000000..5c94a17 --- /dev/null +++ b/internal/agent/custom_test.go @@ -0,0 +1,107 @@ +package agent + +import ( + "os" + "slices" + "strings" + "testing" +) + +func TestRegisterCustom_CopilotBackendSpawn(t *testing.T) { + fakeBin := makeFakeBinary(t, "copilot-local") + t.Setenv("PATH", fakeBin+":"+os.Getenv("PATH")) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + if err := RegisterCustom("copilot-local", "copilot-local", "copilot"); err != nil { + t.Fatalf("RegisterCustom: %v", err) + } + t.Cleanup(func() { delete(registry, "copilot-local") }) + + a, ok := Get("copilot-local") + if !ok { + t.Fatal("custom agent not registered") + } + + orig := DefaultExecer + defer func() { DefaultExecer = orig }() + rec := &recordExecer{} + DefaultExecer = rec + + if err := a.Spawn(Context{}, []string{"-p", "hi"}); err != nil { + t.Fatalf("Spawn: %v", err) + } + if !strings.HasSuffix(rec.argv0, "/copilot-local") { + t.Fatalf("argv0 = %s", rec.argv0) + } + // Copilot backend formatting: --additional-mcp-config @. + if !slices.Contains(rec.argv, "--additional-mcp-config") { + t.Fatalf("argv missing copilot mcp flag: %v", rec.argv) + } + foundAt := false + for _, a := range rec.argv { + if strings.HasPrefix(a, "@") && strings.HasSuffix(a, "/copilot-local.mcp.json") { + foundAt = true + } + } + if !foundAt { + t.Fatalf("argv missing @-prefixed mcp path named after the custom agent: %v", rec.argv) + } +} + +func TestRegisterCustom_ClaudeBackendForbidsKeyAndDefaultsCommand(t *testing.T) { + fakeBin := makeFakeBinary(t, "claude-local") + t.Setenv("PATH", fakeBin+":"+os.Getenv("PATH")) + t.Setenv("ANTHROPIC_API_KEY", "leaked") + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + // Empty command must default to the agent name. + if err := RegisterCustom("claude-local", "", "claude"); err != nil { + t.Fatalf("RegisterCustom: %v", err) + } + t.Cleanup(func() { delete(registry, "claude-local") }) + + orig := DefaultExecer + defer func() { DefaultExecer = orig }() + rec := &recordExecer{} + DefaultExecer = rec + + a, _ := Get("claude-local") + if err := a.Spawn(Context{}, nil); err != nil { + t.Fatalf("Spawn: %v", err) + } + if !strings.HasSuffix(rec.argv0, "/claude-local") { + t.Fatalf("argv0 should default to the agent name: %s", rec.argv0) + } + if !slices.Contains(rec.argv, "--mcp-config") { + t.Fatalf("argv missing claude mcp flag: %v", rec.argv) + } + if hasEnv(rec.env, "ANTHROPIC_API_KEY") { + t.Fatal("ANTHROPIC_API_KEY leaked to claude-backed custom agent") + } +} + +func TestRegisterCustom_UnknownBackend(t *testing.T) { + err := RegisterCustom("weird", "weird", "gemini") + if err == nil { + t.Fatal("expected error for unknown backend") + } + if !strings.Contains(err.Error(), "gemini") { + t.Fatalf("error should mention the bad backend: %v", err) + } + if _, ok := Get("weird"); ok { + t.Fatal("agent with bad backend should not be registered") + } +} + +func TestRegisterCustom_NameCollision(t *testing.T) { + // "copilot" is a built-in; a custom agent must not clobber it. + if err := RegisterCustom("copilot", "copilot-local", "copilot"); err == nil { + t.Fatal("expected error on collision with built-in agent") + } +} + +func TestRegisterCustom_EmptyName(t *testing.T) { + if err := RegisterCustom("", "cmd", "copilot"); err == nil { + t.Fatal("expected error for empty name") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1e5e822..68dd2c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,10 @@ // provider = "sherlock-cli" // base_url = "https://grafana.example" // +// [agents.copilot-local] # a custom wrapper agent +// command = "copilot-local" # binary to exec (defaults to name) +// backend = "copilot" # MCP/-env conventions to follow +// // A service either references a [providers.] block via `provider` // or carries its own inline issuer/client_id (as gitea does, since it // authenticates against its own OAuth server rather than Authentik). @@ -76,11 +80,26 @@ type OAuth struct { Browser string `toml:"browser"` } +// Agent is an operator-defined custom agent: a wrapper command plus the +// backend whose MCP-config and env conventions it follows. It lets an +// operator expose, say, a `copilot-local` wrapper (which points the +// Copilot CLI at a self-hosted model) as `sherlock copilot-local` while +// reusing the built-in "copilot" backend's MCP formatting. +// +// Command is the binary sherlock execs; it defaults to the agent's name +// when empty. Backend selects the MCP/-env conventions and must name a +// backend the agent package knows ("copilot" or "claude"). +type Agent struct { + Command string `toml:"command"` + Backend string `toml:"backend"` +} + // Config is the whole parsed file. type Config struct { OAuth OAuth `toml:"oauth"` Providers map[string]Provider `toml:"providers"` Services map[string]Service `toml:"services"` + Agents map[string]Agent `toml:"agents"` // path is where this config was loaded from, for error messages. path string diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8514899..a802090 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -184,3 +184,47 @@ func TestDefaultPath_XDG(t *testing.T) { t.Fatalf("DefaultPath = %q", got) } } + +func TestAgents_Parsed(t *testing.T) { + path := writeConfig(t, ` +[agents.copilot-local] +command = "copilot-local" +backend = "copilot" + +[agents.claude-local] +backend = "claude" +`) + c, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if len(c.Agents) != 2 { + t.Fatalf("expected 2 agents, got %d: %+v", len(c.Agents), c.Agents) + } + cp := c.Agents["copilot-local"] + if cp.Command != "copilot-local" || cp.Backend != "copilot" { + t.Fatalf("copilot-local = %+v", cp) + } + // command is optional; absence leaves it empty for the agent layer + // to default to the name. + cl := c.Agents["claude-local"] + if cl.Command != "" || cl.Backend != "claude" { + t.Fatalf("claude-local = %+v", cl) + } +} + +func TestAgents_AbsentIsEmpty(t *testing.T) { + path := writeConfig(t, ` +[services.gitea] +issuer = "https://gitea.example" +client_id = "x" +base_url = "https://gitea.example" +`) + c, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if len(c.Agents) != 0 { + t.Fatalf("expected no agents, got %+v", c.Agents) + } +}