5 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
Alexandru Macocian f6e8186b46 Support for custom browser
Release / release (push) Successful in 26s
2026-06-15 17:19:22 +02:00
amacocian c943b1f4fb Fix staticcheck
Release / release (push) Successful in 19s
2026-06-14 21:39:21 +02:00
amacocian 59eaf9e47d SearXNG support
Release / release (push) Failing after 6s
2026-06-14 21:33:22 +02:00
amacocian a96f2afe6f Move to downstream fixes
Release / release (push) Successful in 3m48s
2026-06-13 19:49:52 +02:00
39 changed files with 1145 additions and 99 deletions
+1
View File
@@ -16,5 +16,6 @@ agent CLIs with generated MCP config, and lets MCPs authenticate lazily.
- [gitea-mcp](docs/gitea-mcp.md)
- [grafana-mcp](docs/grafana-mcp.md)
- [gssh-mcp](docs/gssh-mcp.md)
- [searxng-mcp](docs/searxng-mcp.md)
- [gssh integration](docs/gssh-integration.md)
- [Conventions](docs/conventions.md)
+1 -1
View File
@@ -1 +1 @@
0.1.4
0.1.8
+1 -1
View File
@@ -117,7 +117,7 @@ func main() {
OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "gitea-mcp: opening browser for Gitea OAuth login:")
fmt.Fprintln(os.Stderr, " ", u)
if err := browser.Open(u); err != nil {
if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil {
fmt.Fprintln(os.Stderr, "gitea-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
}
+1 -1
View File
@@ -114,7 +114,7 @@ func main() {
OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "grafana-mcp: opening browser for Grafana OAuth login:")
fmt.Fprintln(os.Stderr, " ", u)
if err := browser.Open(u); err != nil {
if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil {
fmt.Fprintln(os.Stderr, "grafana-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
}
+1 -1
View File
@@ -101,7 +101,7 @@ func main() {
OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "gssh-mcp: opening browser for Gssh OAuth login:")
fmt.Fprintln(os.Stderr, " ", u)
if err := browser.Open(u); err != nil {
if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil {
fmt.Fprintln(os.Stderr, "gssh-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
}
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
var ErrUnauthorized = errors.New("searxng: bearer rejected")
type searxngAPI struct {
baseURL string
token func(context.Context) (string, error)
client *http.Client
}
type searchParams struct {
Query string
Categories string
Engines string
Language string
TimeRange string
SafeSearch *int
Page int
Limit int
}
type searxngSearchResponse struct {
Query string `json:"query"`
NumberOfResults int `json:"number_of_results"`
Results []searxngResult `json:"results"`
Suggestions []string `json:"suggestions,omitempty"`
Answers []string `json:"answers,omitempty"`
Corrections []string `json:"corrections,omitempty"`
}
type searxngResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content,omitempty"`
Engine string `json:"engine,omitempty"`
Engines []string `json:"engines,omitempty"`
Category string `json:"category,omitempty"`
Score float64 `json:"score,omitempty"`
PublishedDate string `json:"publishedDate,omitempty"`
ImgSrc string `json:"img_src,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
}
func (s *searxngAPI) search(ctx context.Context, p searchParams) (searxngSearchResponse, error) {
q := url.Values{
"format": []string{"json"},
"q": []string{p.Query},
}
setIf(q, "categories", p.Categories)
setIf(q, "engines", p.Engines)
setIf(q, "language", p.Language)
setIf(q, "time_range", p.TimeRange)
if p.SafeSearch != nil {
q.Set("safesearch", strconv.Itoa(*p.SafeSearch))
}
if p.Page > 0 {
q.Set("pageno", strconv.Itoa(p.Page))
}
full := s.baseURL + "/search?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return searxngSearchResponse{}, err
}
tok, err := s.token(ctx)
if err != nil {
return searxngSearchResponse{}, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return searxngSearchResponse{}, err
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
if err := checkStatus(resp, req); err != nil {
return searxngSearchResponse{}, err
}
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
return searxngSearchResponse{}, fmt.Errorf("searxng: %s returned %q, not JSON; ensure SearXNG enables format=json and Caddy is not serving a login page", req.URL.Path, ct)
}
var out searxngSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return searxngSearchResponse{}, fmt.Errorf("searxng: decode JSON search response: %w", err)
}
if p.Limit > 0 && len(out.Results) > p.Limit {
out.Results = out.Results[:p.Limit]
}
return out, nil
}
func checkStatus(resp *http.Response, req *http.Request) error {
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w by %s (try `sherlock logout searxng` and retry; Caddy must accept the OAuth bearer)", ErrUnauthorized, req.URL.Host)
}
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect {
return fmt.Errorf("searxng: %s %s redirected to %q; Caddy is likely requiring browser-session auth instead of accepting the OAuth bearer", req.Method, req.URL.Path, resp.Header.Get("Location"))
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("searxng: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func setIf(q url.Values, k, v string) {
if v != "" {
q.Set(k, v)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Command searxng-mcp is sherlock's MCP server for SearXNG. It
// authenticates as the operator against Authentik (OIDC + PKCE,
// loopback callback) and sends the resulting bearer token to the
// Caddy-protected SearXNG JSON search API.
//
// Two modes:
//
// searxng-mcp start the stdio MCP server (agents spawn this)
// searxng-mcp --probe run auth + one search, print result, exit
//
// Deployment configuration (the Authentik issuer/client ID and the
// SearXNG base URL) comes entirely from the sherlock config file; there
// are no compiled-in defaults. See docs/configuration.md.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
)
// serviceName is the wallet key under which SearXNG tokens live, and
// the [services.<serviceName>] section read from the config file. The
// sherlock CLI's `logout searxng` clears the same wallet entry.
const serviceName = "searxng"
// Version is overwritten at build time via -ldflags "-X main.Version=...".
var Version = "0.0.0-dev"
// scopes asked from Authentik. `openid` is required for an ID token;
// `profile`/`email` populate user-info claims in `sherlock status`.
var scopes = []string{"openid", "profile", "email"}
func main() {
probe := flag.Bool("probe", false, "run auth + one SearXNG search, print result, exit")
probeQuery := flag.String("probe-query", "sherlock", "query to use with --probe")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Println(Version)
return
}
svc, err := config.LoadService(serviceName)
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
if errors.Is(err, config.ErrNotFound) {
fmt.Fprintln(os.Stderr, "Create it (see docs/configuration.md) or run the sherlock install script.")
}
os.Exit(2)
}
store, err := keyring.Open()
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(3)
}
agent.EnsurePathContainsSiblings()
lockPath, err := xdg.RefreshLockPath()
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
cfg := authn.Config{
Issuer: svc.Issuer,
ClientID: svc.ClientID,
ClientSecret: svc.ClientSecret,
Scopes: scopes,
}
holder := authn.NewTokenHolder()
src := authn.NewTokenSource(store, serviceName, cfg, authn.EnsureOptions{
LockPath: lockPath,
OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "searxng-mcp: opening browser for SearXNG OAuth login:")
fmt.Fprintln(os.Stderr, " ", u)
if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
}
},
}, holder.Set).Lifetime(ctx)
// Lazy auth: the token getter triggers the (possibly browser-opening)
// OAuth flow on the first tool call and launches the background
// renewer. Auth stays off the MCP startup path so the handshake
// completes instantly.
token := func(reqCtx context.Context) (string, error) {
if err := src.EnsureStarted(reqCtx); err != nil {
return "", fmt.Errorf("searxng auth: %w", err)
}
return holder.AccessToken(), nil
}
api := &searxngAPI{
baseURL: strings.TrimRight(svc.BaseURL, "/"),
token: token,
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
if *probe {
// --probe authenticates eagerly: that's the whole point of a probe.
if err := src.EnsureStarted(ctx); err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp: auth:", err)
os.Exit(5)
}
resp, err := api.search(ctx, searchParams{Query: *probeQuery, Limit: 1})
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp: probe:", err)
os.Exit(5)
}
fmt.Printf("OK: searched %s for %q (%d result(s))\n", svc.BaseURL, *probeQuery, len(resp.Results))
return
}
server := mcp.NewServer(&mcp.Implementation{Name: "searxng-mcp", Version: Version}, nil)
registerTools(server, api)
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(1)
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerTools(server *mcp.Server, api *searxngAPI) {
mcp.AddTool(server, &mcp.Tool{
Name: "search_web",
Description: "Search the operator's OAuth-protected SearXNG instance and return JSON search results.",
}, searchWebHandler(api))
}
type searchWebInput struct {
Query string `json:"query" jsonschema:"search query"`
Categories string `json:"categories,omitempty" jsonschema:"optional comma-separated SearXNG categories, e.g. general, images, news"`
Engines string `json:"engines,omitempty" jsonschema:"optional comma-separated SearXNG engines"`
Language string `json:"language,omitempty" jsonschema:"optional SearXNG language code"`
TimeRange string `json:"time_range,omitempty" jsonschema:"optional time range: day, month, year"`
SafeSearch *int `json:"safesearch,omitempty" jsonschema:"optional SearXNG safesearch value: 0, 1, or 2"`
Page int `json:"page,omitempty" jsonschema:"optional result page number, default 1"`
Limit int `json:"limit,omitempty" jsonschema:"max results to return (1-50, default 10)"`
}
type searchWebOutput struct {
Query string `json:"query"`
NumberOfResults int `json:"number_of_results,omitempty"`
Results []searxngResult `json:"results"`
Suggestions []string `json:"suggestions,omitempty"`
Answers []string `json:"answers,omitempty"`
Corrections []string `json:"corrections,omitempty"`
}
func searchWebHandler(api *searxngAPI) mcp.ToolHandlerFor[searchWebInput, searchWebOutput] {
return func(ctx context.Context, _ *mcp.CallToolRequest, in searchWebInput) (*mcp.CallToolResult, searchWebOutput, error) {
query := strings.TrimSpace(in.Query)
if query == "" {
return nil, searchWebOutput{}, errors.New("search_web requires query")
}
limit := clampInt(in.Limit, 1, 50, 10)
page := clampInt(in.Page, 1, 100, 1)
if in.SafeSearch != nil && (*in.SafeSearch < 0 || *in.SafeSearch > 2) {
return nil, searchWebOutput{}, errors.New("safesearch must be 0, 1, or 2")
}
resp, err := api.search(ctx, searchParams{
Query: query,
Categories: strings.TrimSpace(in.Categories),
Engines: strings.TrimSpace(in.Engines),
Language: strings.TrimSpace(in.Language),
TimeRange: strings.TrimSpace(in.TimeRange),
SafeSearch: in.SafeSearch,
Page: page,
Limit: limit,
})
if err != nil {
return nil, searchWebOutput{}, err
}
return nil, searchWebOutput(resp), nil
}
}
func clampInt(v, min, max, dflt int) int {
if v <= 0 {
return dflt
}
if v < min {
return min
}
if v > max {
return max
}
return v
}
+36
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)
@@ -132,6 +140,11 @@ func runStatus(store keyring.Store, _ []string) {
if len(ts.Scopes) > 0 {
fmt.Printf(" scopes: %s\n", strings.Join(ts.Scopes, " "))
}
if !ts.AccessExpAt.IsZero() {
fmt.Printf(" access until %s (%s)\n",
ts.AccessExpAt.Format(time.RFC3339),
humanizeUntil(ts.AccessExpAt))
}
if !ts.IDExpiresAt.IsZero() {
fmt.Printf(" id token until %s (%s)\n",
ts.IDExpiresAt.Format(time.RFC3339),
@@ -220,6 +233,7 @@ func knownMCPs() (mcp.Servers, error) {
"gitea": {Command: "gitea-mcp"},
"grafana": {Command: "grafana-mcp"},
"gssh": {Command: "gssh-mcp"},
"searxng": {Command: "searxng-mcp"},
}
for name, spec := range specs {
abs, err := agent.LookPath(spec.Command)
@@ -233,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 {
+4 -3
View File
@@ -78,9 +78,10 @@ func runUpdate(args []string) {
}
// Non-interactive install against the cache checkout (the installer
// syncs it to the latest tag and bakes that version). Config is left
// untouched — SeedConfig is off, so an update never opens an editor
// or clobbers config.
// syncs it to the latest tag and bakes that version). SeedConfig is
// off, so update never creates a config or opens an editor, but the
// installer may append marked templates for newly shipped config
// sections. Existing values are never clobbered.
if err := installer.Install(context.Background(), installer.Options{
Local: false,
SeedConfig: false,
+1
View File
@@ -26,6 +26,7 @@ complete -c sherlock -n '__fish_seen_subcommand_from run' -f -a 'claude' -d 'An
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gitea' -d 'Forget the gitea session'
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'grafana' -d 'Forget the grafana session'
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gssh' -d 'Forget the gssh session'
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'searxng' -d 'Forget the searxng session'
# ── `sherlock update [--force]` ──────────────────────────────────────
complete -c sherlock -n '__fish_seen_subcommand_from update' -l force -s f -f -d 'Reinstall the latest release even if not newer'
+23
View File
@@ -1,5 +1,11 @@
# sherlock conifugration - lives in $XDG_CONFIG_HOME/sherlock/config.toml
# ── OAuth login ───────────────────────────────────────────────────────
# Optional browser executable/name for OAuth login URLs. Leave unset to
# use the OS default browser (xdg-open/open/rundll32).
# [oauth]
# browser = "firefox"
# ── Providers ────────────────────────────────────────────────────────
# A [providers.<name>] block is a reusable OAuth/OIDC client. Services
# point at one via `provider = "<name>"`. Define a provider once and
@@ -36,3 +42,20 @@ base_url = "https://grafana.example.com"
[services.gssh]
provider = "sherlock-cli"
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"
+31 -6
View File
@@ -13,8 +13,9 @@ equivalent. Unknown agent names exit with usage errors.
## Spawn behavior
On spawn, sherlock resolves installed MCP binaries (`gitea-mcp`, `grafana-mcp`,
`gssh-mcp`), skips missing ones with a warning, renders a 0600 `.mcp.json` file
under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target agent.
`gssh-mcp`, `searxng-mcp`), skips missing ones with a warning, renders a 0600
`.mcp.json` file under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target
agent.
The child mostly inherits the parent environment. The Claude profile strips
`ANTHROPIC_API_KEY` so a personal key does not override sherlock-managed
@@ -22,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>`.)
+7 -6
View File
@@ -5,7 +5,7 @@ master session: each MCP authenticates for the service it calls.
## Lifecycle
For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks
For a service such as `gitea`, `grafana`, `gssh`, or `searxng`, the MCP asks
`internal/authn` for a token on first use:
1. Fresh wallet entry: return it.
@@ -15,17 +15,18 @@ For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks
serialized by `$XDG_RUNTIME_DIR/sherlock.login.lock`, persist, return.
Long-running MCPs use `TokenSource` and `TokenHolder` so requests always read
the latest bearer. Refresh never opens a browser; if refresh can no longer
recover, the next invocation performs a fresh login.
the latest bearer. Refresh follows access-token expiry, never opens a browser,
and falls back to a fresh login on the next invocation if refresh can no longer
recover.
## Service identity
The config supplies issuer, client ID/secret, and base URL. Scopes are owned by
each MCP because they follow the tool surface, not the deployment.
Gitea uses Gitea's OAuth2 server. Grafana and Gssh normally reuse the shared
Authentik `sherlock-cli` provider. Tokens are stored and refreshed per service
and are not reused across unrelated services.
Gitea uses Gitea's OAuth2 server. Grafana, Gssh, and SearXNG normally reuse the
shared Authentik `sherlock-cli` provider. Tokens are stored and refreshed per
service and are not reused across unrelated services.
## User controls
+8
View File
@@ -5,6 +5,10 @@ required fields are hard errors.
Default source: [`config.example.toml`](../config.example.toml).
Setup and update compare an existing config against the shipped example. Missing
provider/service sections are appended as marked templates; existing sections
and values are not rewritten.
## Location
Resolved in order:
@@ -15,6 +19,10 @@ Resolved in order:
## Shape
`[oauth]` is optional. Set `browser = "<executable>"` to open OAuth login URLs
with a specific browser, for example `browser = "firefox"`. Leave it unset to
use the OS default browser.
`[providers.<name>]` defines a reusable OAuth/OIDC identity: `issuer`,
`client_id`, optional `client_secret`.
+3 -2
View File
@@ -30,9 +30,10 @@ Repository rules that should stay true as the project changes.
## Naming
- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`.
- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`, `searxng-mcp`.
- Agent names match the wrapped CLI: `copilot`, `claude`.
- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`.
- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`,
`searxng`.
## Commits and CI
+6 -4
View File
@@ -15,8 +15,9 @@ around the same command.
The installer clones or updates a cached source checkout, checks out the latest
release tag, seeds `config.toml` from `config.example.toml` without overwriting
existing values, optionally opens the config in an editor, installs `sherlock`
plus MCP binaries, and installs shell completions when supported.
existing values, appends marked templates for missing service/provider sections,
optionally opens the config in an editor, installs `sherlock` plus MCP binaries,
and installs shell completions when supported.
One implementation backs both bootstrap setup and `sherlock update`.
@@ -32,7 +33,8 @@ selects the editor.
## Updating
Use `sherlock update` to install a newer release, or `sherlock update --force`
to reinstall the latest release. Updates rebuild binaries and completions but do
not touch existing config.
to reinstall the latest release. Updates rebuild binaries and completions, and
may append marked config templates for newly shipped service/provider sections.
They do not rewrite existing config values.
Re-running setup is safe: it preserves config and reinstalls binaries.
+23
View File
@@ -0,0 +1,23 @@
# searxng-mcp
Stdio MCP for a SearXNG instance. It exposes web search through SearXNG's JSON
`/search` API. Exact tool schemas live in `cmd/searxng-mcp/tools.go`.
## Auth
`searxng-mcp` uses sherlock-managed OAuth with wallet key `searxng`, normally
through the shared Authentik `sherlock-cli` provider. It sends a fresh bearer to
the Caddy-protected SearXNG origin on every search request.
Caddy must accept that OAuth bearer for API requests. Browser SSO redirects or
session cookies alone are not enough for an MCP subprocess. SearXNG must also
enable JSON output for `/search?format=json`.
Config is `[services.searxng]` with provider or inline OAuth identity plus
`base_url`. `sherlock logout searxng` clears the session.
## Operation
When `searxng-mcp` is installed, `sherlock <agent>` includes it in the generated
MCP config. The first tool call authenticates lazily. `searxng-mcp --probe`
verifies auth against Caddy and one SearXNG JSON search without an agent.
+1 -3
View File
@@ -7,7 +7,7 @@ require (
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/grafana/mcp-grafana v0.15.2
github.com/grafana/mcp-grafana v0.15.3-0.20260613165141-77d81c6b7389
github.com/mark3labs/mcp-go v0.46.0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/zalando/go-keyring v0.2.8
@@ -172,5 +172,3 @@ require (
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/grafana/mcp-grafana => github.com/AlexMacocian/mcp-grafana v0.0.0-20260613143711-1f1200568046
+2 -2
View File
@@ -7,8 +7,6 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
github.com/AlexMacocian/mcp-grafana v0.0.0-20260613143711-1f1200568046 h1:W2wS3aOdAjhEc/JH8jBDY95YKiU7oq0U9KqoGtwz9M0=
github.com/AlexMacocian/mcp-grafana v0.0.0-20260613143711-1f1200568046/go.mod h1:LT3LLGh4ZmjqasENuFicV8iJkyuGuVNZztyPolDlBqI=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
@@ -206,6 +204,8 @@ github.com/grafana/grafana-plugin-sdk-go v0.290.1 h1:wNX4R8sHxEAmtmFhmV05IsLGznz
github.com/grafana/grafana-plugin-sdk-go v0.290.1/go.mod h1:KDkcxp1XqbKz0WD/q9p98Cf5Wp50LG0NkoPlVlptSWs=
github.com/grafana/incident-go v0.0.0-20251003115753-d71681611ddd h1:y8uJA/UmFHjwNWAvppxDRq+w9zIQmb0z79YVV2vS96g=
github.com/grafana/incident-go v0.0.0-20251003115753-d71681611ddd/go.mod h1:3QDfdZOWKRxNhMJFL+0C/+12+jLNHDlt0VKNr/i9Daw=
github.com/grafana/mcp-grafana v0.15.3-0.20260613165141-77d81c6b7389 h1:teRFmGepvMKVA/VNj/IcQkJuS3q0Rijk5yYSCmiLbxE=
github.com/grafana/mcp-grafana v0.15.3-0.20260613165141-77d81c6b7389/go.mod h1:LT3LLGh4ZmjqasENuFicV8iJkyuGuVNZztyPolDlBqI=
github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8=
github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
+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")
}
}
+9
View File
@@ -205,6 +205,7 @@ func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
IDExpiresAt: idTok.Expiry,
AccessExpAt: accessExpiry(tok.Expiry, idTok.Expiry),
RefreshExpAt: refreshExpiry(tok),
Issuer: cfg.Issuer,
ClientID: cfg.ClientID,
@@ -239,12 +240,20 @@ func splitScope(s string) []string {
if i > start {
out = append(out, s[start:i])
}
start = i + 1
}
}
return out
}
func accessExpiry(access, fallback time.Time) time.Time {
if !access.IsZero() {
return access
}
return fallback
}
// refreshExpiry pulls Authentik's `refresh_expires_in` (an extension
// to RFC 6749). If absent we conservatively assume 30 days, matching
// Authentik's default.
+3
View File
@@ -246,6 +246,9 @@ func TestLogin_HappyPath(t *testing.T) {
if res.Tokens.ClientID != "test-client" {
t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID)
}
if res.Tokens.AccessExpAt.IsZero() {
t.Fatal("AccessExpAt was not persisted")
}
if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") {
t.Fatalf("Issuer = %q", res.Tokens.Issuer)
}
+16 -5
View File
@@ -17,8 +17,8 @@ import (
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// RefreshSkew is how much headroom EnsureFresh leaves before the ID
// token actually expires. Refreshing 30s ahead of expiry avoids
// RefreshSkew is how much headroom EnsureFresh leaves before the
// bearer token actually expires. Refreshing 30s ahead of expiry avoids
// shipping a token that will be rejected by clock-skewed resource
// servers.
const RefreshSkew = 30 * time.Second
@@ -39,7 +39,7 @@ type EnsureFreshOptions struct {
}
// EnsureFresh returns the stored TokenSet for service, refreshing it
// against its OAuth issuer if the ID token has < RefreshSkew left.
// against its OAuth issuer if the bearer token has < RefreshSkew left.
// Cross-process safe: takes an exclusive flock on opts.LockPath,
// re-reads the keyring after acquiring the lock, and skips the
// refresh if a concurrent process already rotated the tokens.
@@ -68,7 +68,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts
if ts.Empty() {
return ts, keyring.ErrNoTokens
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
return ts, nil
}
@@ -93,7 +93,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts
if err != nil {
return ts, err
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
return ts, nil
}
if ts.RefreshToken == "" {
@@ -151,7 +151,18 @@ func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (key
out.RefreshToken = tok.RefreshToken
}
out.IDExpiresAt = idTok.Expiry
out.AccessExpAt = accessExpiry(tok.Expiry, idTok.Expiry)
out.RefreshExpAt = refreshExpiry(tok)
out.Scopes = grantedScopes(tok, ts.Scopes)
return out, nil
}
func expiryForRefresh(ts keyring.TokenSet) time.Time {
if !ts.AccessExpAt.IsZero() {
return ts.AccessExpAt
}
if ts.AccessToken != "" && ts.RefreshToken != "" {
return time.Time{}
}
return ts.IDExpiresAt
}
+2 -2
View File
@@ -197,7 +197,7 @@ func (s *TokenSource) Run(ctx context.Context) error {
}
// nextWait computes how long to sleep before the next proactive
// renewal: skew before the cached token expires, clamped to minWait so
// renewal: skew before the bearer token expires, clamped to minWait so
// a very short TTL doesn't spin.
func (s *TokenSource) nextWait() time.Duration {
s.mu.Lock()
@@ -206,7 +206,7 @@ func (s *TokenSource) nextWait() time.Duration {
if cur.Empty() {
return s.minWait
}
wait := cur.IDExpiresAt.Sub(s.clock()) - s.skew
wait := expiryForRefresh(cur).Sub(s.clock()) - s.skew
if wait < s.minWait {
return s.minWait
}
+9 -3
View File
@@ -73,13 +73,16 @@ func TestTokenSource_Start_DeliversInitialToken(t *testing.T) {
func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
stub := newStubAuthentik(t)
store := fakekeyring.New()
// Seed a STALE token (already expired) with a refresh token so the
// renewal path fires immediately.
// Seed a stale bearer with a still-fresh ID token. This mirrors
// providers that issue short-lived access tokens and longer-lived ID
// tokens; refresh must follow the bearer lifetime because MCPs send
// the access token to services.
_ = store.Set("gitea", keyring.TokenSet{
IDToken: "old",
AccessToken: "at-stale",
RefreshToken: "rt-1",
IDExpiresAt: time.Now().Add(-time.Minute),
AccessExpAt: time.Now().Add(-time.Minute),
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: stub.srv.URL,
ClientID: "test-client",
Scopes: []string{"openid"},
@@ -114,6 +117,9 @@ func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
if stored.AccessToken != "at-refreshed" {
t.Fatalf("wallet access token = %q, want at-refreshed", stored.AccessToken)
}
if stored.AccessExpAt.IsZero() {
t.Fatal("wallet access token expiry was not persisted")
}
cancel()
select {
+23 -7
View File
@@ -7,6 +7,7 @@ package browser
import (
"os/exec"
"runtime"
"strings"
)
// Open starts the OS's default browser pointed at url, then returns
@@ -15,9 +16,29 @@ import (
// itself reports — there's no portable way to know whether the user
// actually loaded the page.
func Open(url string) error {
return OpenWith("", url)
}
// OpenWith starts browser pointed at url, then returns immediately. If
// browser is empty, it falls back to the OS default helper used by Open.
func OpenWith(browser, url string) error {
bin, args := commandFor(browser, url, runtime.GOOS)
cmd := exec.Command(bin, args...)
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
}
func commandFor(browser, url, goos string) (string, []string) {
if browser = strings.TrimSpace(browser); browser != "" {
return browser, []string{url}
}
var bin string
var args []string
switch runtime.GOOS {
switch goos {
case "darwin":
bin = "open"
args = []string{url}
@@ -28,10 +49,5 @@ func Open(url string) error {
bin = "xdg-open"
args = []string{url}
}
cmd := exec.Command(bin, args...)
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
return bin, args
}
+36
View File
@@ -0,0 +1,36 @@
package browser
import (
"reflect"
"testing"
)
func TestCommandFor_Defaults(t *testing.T) {
tests := []struct {
name string
goos string
wantBin string
wantArgs []string
}{
{name: "linux", goos: "linux", wantBin: "xdg-open", wantArgs: []string{"https://example.com"}},
{name: "darwin", goos: "darwin", wantBin: "open", wantArgs: []string{"https://example.com"}},
{name: "windows", goos: "windows", wantBin: "rundll32", wantArgs: []string{"url.dll,FileProtocolHandler", "https://example.com"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotBin, gotArgs := commandFor("", "https://example.com", tt.goos)
if gotBin != tt.wantBin || !reflect.DeepEqual(gotArgs, tt.wantArgs) {
t.Fatalf("commandFor = %q %v, want %q %v", gotBin, gotArgs, tt.wantBin, tt.wantArgs)
}
})
}
}
func TestCommandFor_CustomBrowser(t *testing.T) {
gotBin, gotArgs := commandFor(" firefox ", "https://example.com", "linux")
wantArgs := []string{"https://example.com"}
if gotBin != "firefox" || !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("commandFor = %q %v, want firefox %v", gotBin, gotArgs, wantArgs)
}
}
+32
View File
@@ -9,6 +9,9 @@
//
// Shape:
//
// [oauth]
// browser = "firefox" # optional; default OS browser when empty
//
// [providers.sherlock-cli] # a reusable OAuth provider
// issuer = "https://id.example/application/o/sherlock-cli/"
// client_id = "abc123"
@@ -22,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).
@@ -66,10 +73,33 @@ type Service struct {
BaseURL string `toml:"base_url"`
}
// OAuth contains global OAuth-flow preferences.
type OAuth struct {
// Browser is an optional executable name or path used to open OAuth
// login URLs. Empty means the OS default browser.
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
@@ -83,6 +113,7 @@ type Resolved struct {
ClientID string
ClientSecret string
BaseURL string
OAuthBrowser string
}
// DefaultPath returns the config-file path sherlock reads, honouring
@@ -155,6 +186,7 @@ func (c *Config) Service(name string) (Resolved, error) {
ClientID: svc.ClientID,
ClientSecret: svc.ClientSecret,
BaseURL: svc.BaseURL,
OAuthBrowser: c.OAuth.Browser,
}
if svc.Provider != "" {
+64
View File
@@ -63,6 +63,26 @@ base_url = "https://gitea.example"
}
}
func TestService_OAuthBrowser(t *testing.T) {
path := writeConfig(t, `
[oauth]
browser = "firefox"
[services.gitea]
issuer = "https://gitea.example"
client_id = "gitea-client"
base_url = "https://gitea.example"
`)
c, _ := LoadFrom(path)
got, err := c.Service("gitea")
if err != nil {
t.Fatalf("Service: %v", err)
}
if got.OAuthBrowser != "firefox" {
t.Fatalf("OAuthBrowser = %q", got.OAuthBrowser)
}
}
func TestService_UnknownService(t *testing.T) {
path := writeConfig(t, `
[services.gitea]
@@ -164,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)
}
}
+108
View File
@@ -53,6 +53,114 @@ func seedConfig(opts *Options, example, cfgPath string) error {
return os.WriteFile(cfgPath, data, 0o600)
}
// appendMissingConfigSections compares the operator config with the
// shipped example and appends any missing [providers.*] or [services.*]
// sections as clearly marked templates. It never rewrites existing
// sections, so operator edits are preserved.
func appendMissingConfigSections(opts *Options, example, cfgPath string) ([]string, error) {
cfg, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
opts.warn("no config found at %s; run setup with --config-only to create one.", cfgPath)
return nil, nil
}
return nil, err
}
exampleBody, err := os.ReadFile(example)
if err != nil {
return nil, err
}
existing := sectionSet(string(cfg))
blocks := configSectionBlocks(string(exampleBody))
var missing []configSectionBlock
for _, b := range blocks {
if !existing[b.Name] {
missing = append(missing, b)
}
}
if len(missing) == 0 {
return nil, nil
}
var out strings.Builder
out.WriteString(string(cfg))
if len(cfg) > 0 && cfg[len(cfg)-1] != '\n' {
out.WriteByte('\n')
}
for _, b := range missing {
out.WriteString("\n# Added by sherlock because this section was missing from config.toml.\n")
out.WriteString("# Template copied from config.example.toml; fill placeholders before use.\n")
out.WriteString(strings.TrimSpace(b.Body))
out.WriteByte('\n')
}
if err := os.WriteFile(cfgPath, []byte(out.String()), 0o600); err != nil {
return nil, err
}
names := make([]string, 0, len(missing))
for _, b := range missing {
names = append(names, b.Name)
}
return names, nil
}
type configSectionBlock struct {
Name string
Body string
}
func sectionSet(body string) map[string]bool {
out := map[string]bool{}
for _, line := range strings.Split(body, "\n") {
if name, ok := configSectionName(line); ok {
out[name] = true
}
}
return out
}
func configSectionBlocks(body string) []configSectionBlock {
lines := strings.Split(body, "\n")
var blocks []configSectionBlock
for i := 0; i < len(lines); i++ {
name, ok := configSectionName(lines[i])
if !ok {
continue
}
start := i
end := len(lines)
for j := i + 1; j < len(lines); j++ {
if _, ok := configSectionName(lines[j]); ok {
end = j
break
}
}
blocks = append(blocks, configSectionBlock{
Name: name,
Body: strings.Join(lines[start:end], "\n"),
})
i = end - 1
}
return blocks
}
func configSectionName(line string) (string, bool) {
s := strings.TrimSpace(line)
if !strings.HasPrefix(s, "[") {
return "", false
}
end := strings.IndexByte(s, ']')
if end < 0 {
return "", false
}
name := s[1:end]
if strings.HasPrefix(name, "providers.") || strings.HasPrefix(name, "services.") {
return name, true
}
return "", false
}
// pickEditor resolves the editor command: $VISUAL, then $EDITOR, then
// the first of sensible-editor/nano/vi found on PATH. Returns "" if none.
func pickEditor() string {
+19 -6
View File
@@ -31,7 +31,7 @@ const devVersion = "0.0.0-dev"
// binaries are the cmd/<name> packages installed. `go install ./cmd/...`
// covers exactly these (the setup entry lives outside cmd/, so it is
// never installed).
var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp"}
var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp", "searxng-mcp"}
// Options controls one Install run.
type Options struct {
@@ -49,7 +49,8 @@ type Options struct {
// SeedConfig copies config.example.toml to the operator's config
// path when none exists yet (never clobbers a filled-in file).
SeedConfig bool
// OpenEditor opens $VISUAL/$EDITOR on the config after seeding.
// OpenEditor opens $VISUAL/$EDITOR on the config after seeding and
// appending any missing section templates.
OpenEditor bool
// ConfigOnly stops after the config step (no build/install).
ConfigOnly bool
@@ -97,14 +98,26 @@ func Install(ctx context.Context, opts Options) error {
return fmt.Errorf("config.example.toml not found in the source (%s): %w", example, err)
}
cfgPath, err := config.DefaultPath()
if err != nil {
return err
}
if opts.SeedConfig {
cfgPath, err := config.DefaultPath()
if err != nil {
return err
}
if err := seedConfig(&opts, example, cfgPath); err != nil {
return err
}
}
addedSections, err := appendMissingConfigSections(&opts, example, cfgPath)
if err != nil {
return err
}
if len(addedSections) > 0 {
opts.warn("%s was missing section(s): %s. Appended marked templates from config.example.toml; fill placeholders before use.",
cfgPath, strings.Join(addedSections, ", "))
}
if opts.SeedConfig {
if opts.OpenEditor {
if err := openEditor(&opts, cfgPath); err != nil {
return err
+63
View File
@@ -3,6 +3,7 @@ package installer
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -70,6 +71,68 @@ func TestSeedConfig_DoesNotClobber(t *testing.T) {
}
}
func TestAppendMissingConfigSections(t *testing.T) {
dir := t.TempDir()
example := filepath.Join(dir, "config.example.toml")
cfg := filepath.Join(dir, "config.toml")
if err := os.WriteFile(example, []byte(`
[providers.sherlock-cli]
issuer = "https://id.example/"
client_id = "REPLACE"
[services.gitea]
issuer = "https://gitea.example"
client_id = "gitea"
base_url = "https://gitea.example"
[services.searxng]
provider = "sherlock-cli"
base_url = "https://search.example"
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfg, []byte(`
[providers.sherlock-cli]
issuer = "https://id.real/"
client_id = "real"
[services.gitea]
issuer = "https://gitea.real"
client_id = "gitea-real"
base_url = "https://gitea.real"
`), 0o600); err != nil {
t.Fatal(err)
}
opts := &Options{Out: io_discard{}, Err: io_discard{}}
added, err := appendMissingConfigSections(opts, example, cfg)
if err != nil {
t.Fatalf("appendMissingConfigSections: %v", err)
}
if len(added) != 1 || added[0] != "services.searxng" {
t.Fatalf("added = %v, want [services.searxng]", added)
}
b, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
body := string(b)
if !strings.Contains(body, "Added by sherlock") {
t.Fatalf("missing marker comment in %q", body)
}
if strings.Count(body, "[services.searxng]") != 1 {
t.Fatalf("searxng section count wrong in %q", body)
}
added, err = appendMissingConfigSections(opts, example, cfg)
if err != nil {
t.Fatalf("second appendMissingConfigSections: %v", err)
}
if len(added) != 0 {
t.Fatalf("second added = %v, want none", added)
}
}
func TestFishCompletionsDir(t *testing.T) {
tmp := t.TempDir()
t.Setenv("__fish_config_dir", "")
+1
View File
@@ -139,6 +139,7 @@ type TokenSet struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
AccessExpAt time.Time `json:"access_expires_at,omitempty"`
IDExpiresAt time.Time `json:"id_expires_at"`
RefreshExpAt time.Time `json:"refresh_expires_at"`
Issuer string `json:"issuer"`