1 Commits

Author SHA1 Message Date
Alexandru Macocian 84958c7ef3 Support for local agents
Release / release (push) Successful in 17s
2026-06-15 19:03:56 +02:00
12 changed files with 395 additions and 51 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.7
0.1.8
+30
View File
@@ -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.<name>]` 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 {
+13
View File
@@ -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.<name>] block exposes an operator-owned wrapper command as
# `sherlock <name>`, 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 <name> 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"
+28 -4
View File
@@ -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
@<path>` vs `--mcp-config <path>`) and what env to scrub. The rendered
MCP file is named after the agent (`<name>.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 <name>`.)
+11 -5
View File
@@ -1,9 +1,15 @@
// Package agent dispatches `sherlock <name>` 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.<name>]` 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
+78
View File
@@ -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.<name>].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
}
+4 -21
View File
@@ -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)
}
+4 -20
View File
@@ -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)
}
+56
View File
@@ -0,0 +1,56 @@
package agent
import "fmt"
// custom is an operator-defined agent declared in config.toml's
// `[agents.<name>]` 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 <name>`.
// - 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
}
+107
View File
@@ -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 @<path>.
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")
}
}
+19
View File
@@ -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.<name>] 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
+44
View File
@@ -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)
}
}