243 lines
6.8 KiB
Go
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()
|
|
}
|