Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84958c7ef3 | |||
| f6e8186b46 | |||
| c943b1f4fb | |||
| 59eaf9e47d | |||
| a96f2afe6f |
@@ -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)
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user