Files
sherlock/cmd/sherlock/main.go
T
amacocian 1c2108dd2b
Release / tag (push) Successful in 2s
CI / build (push) Successful in 3m5s
Fix fmt
2026-06-13 13:40:24 +02:00

243 lines
6.8 KiB
Go

// Command sherlock is the operator's single binary. It is a wallet
// of OAuth sessions (one per service: gitea, grafana, miniflux, …)
// plus an agent-CLI wrapper that execs Copilot, Claude Code, etc.
//
// There is no `sherlock login` step. Authentication happens lazily,
// when an MCP started by the agent calls authn.Ensure for the first
// time. Sherlock's job is to provide the shared auth library and the
// wallet; this binary exposes the wallet via `sherlock status` and
// `sherlock logout`, and dispatches `sherlock <agent>` to the right
// CLI integration. See docs/architecture.md.
package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
)
// Version is overwritten at build time via -ldflags "-X main.Version=...".
var Version = "0.0.0-dev"
const (
exitGeneric = 1
exitUsage = 2
exitKeyring = 3
)
func main() {
if len(os.Args) < 2 {
usage(os.Stderr)
os.Exit(exitUsage)
}
sub := os.Args[1]
rest := os.Args[2:]
switch sub {
case "version", "--version", "-v":
runVersion()
return
case "help", "--help", "-h":
usage(os.Stdout)
return
}
store, err := keyring.Open()
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
os.Exit(exitKeyring)
}
// Make sure every child process can find the MCPs we shipped via
// `go install` even if the operator never touched their shell rc.
agent.EnsurePathContainsSiblings()
switch sub {
case "status":
runStatus(store, rest)
case "logout":
runLogout(store, rest)
case "update":
runUpdate(rest)
case "run":
if len(rest) == 0 {
fmt.Fprintln(os.Stderr, "sherlock: run requires an agent name")
os.Exit(exitUsage)
}
runAgent(rest[0], rest[1:])
default:
if _, ok := agent.Get(sub); ok {
runAgent(sub, rest)
return
}
fmt.Fprintf(os.Stderr, "sherlock: unknown subcommand %q\n", sub)
usage(os.Stderr)
os.Exit(exitUsage)
}
}
// runVersion prints the running version and, for release builds, does a
// bounded best-effort check for a newer one.
func runVersion() {
fmt.Println(Version)
notifyUpdateAvailable()
}
func usage(w *os.File) {
_, _ = fmt.Fprintf(w, `sherlock - Charlie credential broker + agent wrapper
Usage:
sherlock <subcommand> [args...]
Subcommands:
status Show wallet contents (one entry per service).
logout [<service>] Forget all stored tokens, or just one service's.
run <agent> [args...] Spawn an agent.
<agent> [args...] Alias for `+"`run <agent> ...`"+`. Known agents: %s.
update [--force] Update sherlock + MCPs to the latest release.
version Print the sherlock version and exit.
Authentication is not a user-initiated step. The first time an MCP
needs a token for a service, it triggers the OAuth flow itself via
internal/authn.Ensure (which opens a browser, persists the result in
the wallet, and silently refreshes thereafter). Use `+"`sherlock status`"+`
to see what the wallet currently holds.
`, strings.Join(agent.Names(), ", "))
}
func runStatus(store keyring.Store, _ []string) {
names, err := store.List()
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock: list wallet:", err)
os.Exit(exitKeyring)
}
if len(names) == 0 {
fmt.Println("Wallet is empty. MCPs will authenticate on first use.")
} else {
fmt.Println("Wallet:")
for _, n := range names {
ts, err := store.Get(n)
if err != nil {
fmt.Printf(" - %-12s (error: %v)\n", n, err)
continue
}
fmt.Printf(" - %-12s %s <%s>\n", n, ts.Name, ts.Email)
fmt.Printf(" issuer: %s\n", ts.Issuer)
if len(ts.Scopes) > 0 {
fmt.Printf(" scopes: %s\n", strings.Join(ts.Scopes, " "))
}
if !ts.IDExpiresAt.IsZero() {
fmt.Printf(" id token until %s (%s)\n",
ts.IDExpiresAt.Format(time.RFC3339),
humanizeUntil(ts.IDExpiresAt))
}
if !ts.RefreshExpAt.IsZero() {
fmt.Printf(" refresh until %s (%s)\n",
ts.RefreshExpAt.Format(time.RFC3339),
humanizeUntil(ts.RefreshExpAt))
}
}
}
if names := agent.Names(); len(names) > 0 {
fmt.Println("Available agents:")
for _, n := range names {
a, _ := agent.Get(n)
fmt.Printf(" - %-10s %s\n", n, a.Description())
}
}
}
func runLogout(store keyring.Store, args []string) {
if len(args) == 0 {
names, err := store.List()
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock: list wallet:", err)
os.Exit(exitKeyring)
}
if len(names) == 0 {
fmt.Println("Wallet was already empty.")
return
}
for _, n := range names {
if err := store.Clear(n); err != nil {
fmt.Fprintln(os.Stderr, "sherlock: clear", n+":", err)
os.Exit(exitKeyring)
}
}
fmt.Printf("Forgot %d session(s): %s.\n", len(names), strings.Join(names, ", "))
return
}
name := args[0]
if _, err := store.Get(name); errors.Is(err, keyring.ErrNoTokens) {
fmt.Printf("No session stored for %q.\n", name)
return
} else if err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
os.Exit(exitKeyring)
}
if err := store.Clear(name); err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
os.Exit(exitKeyring)
}
fmt.Printf("Forgot %q.\n", name)
}
func runAgent(name string, userArgs []string) {
a, ok := agent.Get(name)
if !ok {
fmt.Fprintf(os.Stderr, "sherlock: unknown agent %q. Known: %v\n", name, agent.Names())
os.Exit(exitUsage)
}
servers, err := knownMCPs()
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
os.Exit(exitGeneric)
}
// Best-effort, bounded update hint before we hand off to the agent.
// Spawn replaces this process, so the check has to happen here.
notifyUpdateAvailable()
if err := a.Spawn(agent.Context{Servers: servers}, userArgs); err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
os.Exit(exitGeneric)
}
}
// knownMCPs returns the stdio MCP servers sherlock wants every agent
// to know about. Commands are resolved to absolute paths so the agent
// CLI can spawn them regardless of how its own PATH is set up
// (Copilot's Node-based subprocess spawner ignores parent env tweaks).
// Hardcoded for now — when this grows more knobs we'll extract it to an
// internal/mcp/registry/ package that mirrors the agent registry pattern.
func knownMCPs() (mcp.Servers, error) {
out := mcp.Servers{}
specs := map[string]mcp.Server{
"gitea": {Command: "gitea-mcp"},
"grafana": {Command: "grafana-mcp"},
"gssh": {Command: "gssh-mcp"},
}
for name, spec := range specs {
abs, err := agent.LookPath(spec.Command)
if err != nil {
fmt.Fprintf(os.Stderr, "sherlock: skipping mcp %q: %v\n", name, err)
continue
}
spec.Command = abs
out[name] = spec
}
return out, nil
}
func humanizeUntil(t time.Time) string {
d := time.Until(t).Round(time.Second)
if d < 0 {
return "expired"
}
return "in " + d.String()
}