Fix fmt
Release / tag (push) Successful in 2s
CI / build (push) Successful in 3m5s

This commit is contained in:
2026-06-13 13:40:24 +02:00
parent 40a779d4a8
commit 1c2108dd2b
33 changed files with 741 additions and 93 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Release
# Auto-tags releases from the VERSION file. On every push to main it
# reads VERSION (e.g. "0.1.0") and, if the tag "v0.1.0" does not already
# exist, creates and pushes it at the pushed commit. Bumping the release
# is therefore a one-line edit to VERSION — no manual tagging.
#
# Token: pushing a tag needs write access. Gitea injects a per-job
# `github.token`; this workflow uses it, so the repository's
# Settings → Actions → General → "Workflow permissions" must allow
# read/write. If your instance restricts the auto token, set a PAT
# secret named RELEASE_TAG_PAT (repo write) and it will be used instead.
#
# Runner notes mirror ci.yaml: no actions/checkout + setup-go (the
# homelab act_runner re-clones JS actions per job and stalls); we use
# plain git instead.
on:
push:
branches: [main]
workflow_dispatch:
jobs:
tag:
runs-on: self-hosted
defaults:
run:
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG_PAT: ${{ secrets.RELEASE_TAG_PAT }}
steps:
- name: Tag release from VERSION
run: |
set -eu
# Prefer an explicit PAT if provided, else the per-job token.
token="${RELEASE_TAG_PAT:-$GITHUB_TOKEN}"
if [ -z "$token" ]; then
echo "no token available to push tags" >&2
exit 1
fi
auth=$(printf '%s' "x-access-token:${token}" | base64 -w0)
remote="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
work="$(mktemp -d)"
cd "$work"
git init -q
git remote add origin "$remote"
git -c "http.extraheader=Authorization: Basic ${auth}" \
fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q FETCH_HEAD
# Fetch existing tags so we can check for the target tag.
git -c "http.extraheader=Authorization: Basic ${auth}" \
fetch --quiet --tags origin || true
version="$(tr -d ' \t\r\n' < VERSION)"
if [ -z "$version" ]; then
echo "VERSION file is empty" >&2
exit 1
fi
tag="v${version}"
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "Tag ${tag} already exists — nothing to release."
exit 0
fi
# Also guard against a remote tag the shallow fetch missed.
if git -c "http.extraheader=Authorization: Basic ${auth}" \
ls-remote --tags origin "refs/tags/${tag}" | grep -q "${tag}"; then
echo "Tag ${tag} already exists on the remote — nothing to release."
exit 0
fi
echo "Creating tag ${tag} at ${GITHUB_SHA}"
git config user.name "sherlock-release"
git config user.email "release@sherlock.local"
git tag -a "${tag}" -m "Release ${tag}"
git -c "http.extraheader=Authorization: Basic ${auth}" \
push origin "refs/tags/${tag}"
echo "Pushed ${tag}."
+1
View File
@@ -7,6 +7,7 @@ Per-operator credential wallet + agent-CLI wrapper for the Charlie homelab. Hold
- [Installation](docs/installation.md)
- [Architecture](docs/architecture.md)
- [Configuration](docs/configuration.md)
- [Versioning & self-update](docs/versioning.md)
- [Auth model](docs/auth-model.md)
- [Storage & keyring](docs/storage.md)
- [Agents](docs/agents.md)
+1
View File
@@ -0,0 +1 @@
0.1.1
+6 -6
View File
@@ -33,12 +33,12 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/config"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
"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 gitea tokens live, and the
+13 -13
View File
@@ -208,17 +208,17 @@ func getTreeHandler(api *giteaAPI) mcp.ToolHandlerFor[getTreeInput, getTreeOutpu
// isHexSHA reports whether s looks like a 7-40 char hex git SHA.
func isHexSHA(s string) bool {
if len(s) < 7 || len(s) > 40 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
if len(s) < 7 || len(s) > 40 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
+7 -7
View File
@@ -46,12 +46,12 @@ import (
"github.com/grafana/mcp-grafana/tools"
"github.com/mark3labs/mcp-go/server"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/config"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
"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 grafana tokens live, and
@@ -244,7 +244,7 @@ func probeGrafana(ctx context.Context, baseURL, token string) error {
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+6 -6
View File
@@ -32,12 +32,12 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/browser"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/config"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
"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 gssh tokens live, and the
+2 -2
View File
@@ -14,7 +14,7 @@ import (
"github.com/coder/websocket"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/wsdatagram"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/wsdatagram"
)
// hardClientTimeout caps the entire run_command tool call. The server
@@ -108,7 +108,7 @@ func execOverWebSocket(parent context.Context, api *gsshAPI, host, command strin
// Per coder/websocket convention, always Close to release the
// goroutine. StatusNormalClosure is the polite signal back to the
// server even on errors; the server already finalised by then.
defer conn.Close(websocket.StatusNormalClosure, "")
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
// Lift the per-message size limit. The default 32 KiB is enough
// for one datagram (2 KiB) but the lift is cheap defensive code
+17 -4
View File
@@ -17,9 +17,9 @@ import (
"strings"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/agent"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
"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=...".
@@ -41,7 +41,7 @@ func main() {
switch sub {
case "version", "--version", "-v":
fmt.Println(Version)
runVersion()
return
case "help", "--help", "-h":
usage(os.Stdout)
@@ -63,6 +63,8 @@ func main() {
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")
@@ -80,6 +82,13 @@ func main() {
}
}
// 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
@@ -91,6 +100,7 @@ Subcommands:
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
@@ -189,6 +199,9 @@ func runAgent(name string, userArgs []string) {
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)
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/selfupdate"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
)
// updateCheckTimeout bounds the best-effort passive update check so it
// never meaningfully delays a command. On the homelab LAN the Gitea
// tags API answers in well under this; if it doesn't (offline, slow),
// the hint is silently skipped.
const updateCheckTimeout = 1500 * time.Millisecond
// notifyUpdateAvailable runs a bounded, best-effort check and prints a
// one-line hint to stderr if a newer release exists. It never blocks
// for longer than updateCheckTimeout and never fails the caller. No-op
// for dev builds or when $SHERLOCK_NO_UPDATE_CHECK is set.
func notifyUpdateAvailable() {
if os.Getenv(selfupdate.EnvDisable) != "" {
return
}
if Version == selfupdate.DevVersion {
return
}
ctx, cancel := context.WithTimeout(context.Background(), updateCheckTimeout)
defer cancel()
res, err := selfupdate.CheckForUpdate(ctx, Version, nil)
if err != nil || !res.Available {
return
}
fmt.Fprintf(os.Stderr,
"sherlock: a newer version is available (%s → %s). Run `sherlock update`.\n",
res.Current, res.Latest)
}
// runUpdate checks for the latest release and, if newer than the
// running binary (or if forced), updates the source checkout under
// $XDG_CACHE_HOME/sherlock/src and reinstalls all binaries from it.
func runUpdate(args []string) {
force := false
for _, a := range args {
switch a {
case "-f", "--force":
force = true
default:
fmt.Fprintf(os.Stderr, "sherlock update: unknown flag %q\n", a)
os.Exit(exitUsage)
}
}
if _, err := exec.LookPath("git"); err != nil {
fmt.Fprintln(os.Stderr, "sherlock update: git is required but was not found on PATH.")
os.Exit(exitGeneric)
}
if _, err := exec.LookPath("go"); err != nil {
fmt.Fprintln(os.Stderr, "sherlock update: the Go toolchain is required but 'go' was not found on PATH.")
os.Exit(exitGeneric)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
latest, err := selfupdate.LatestTag(ctx, nil)
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock update: checking for latest release:", err)
os.Exit(exitGeneric)
}
if latest == "" {
fmt.Fprintln(os.Stderr, "sherlock update: the repository has no published version tags yet.")
os.Exit(exitGeneric)
}
res, _ := selfupdate.CheckForUpdate(context.Background(), Version, nil)
if !res.Available && !force {
if Version == selfupdate.DevVersion {
fmt.Printf("Running a dev build; latest release is %s. Use `sherlock update --force` to install it.\n", latest)
} else {
fmt.Printf("Already up to date (%s).\n", latest)
}
return
}
src, err := updateSourceCheckout(latest)
if err != nil {
fmt.Fprintln(os.Stderr, "sherlock update:", err)
os.Exit(exitGeneric)
}
fmt.Printf("Installing %s …\n", latest)
if err := goInstallAll(src, latest); err != nil {
fmt.Fprintln(os.Stderr, "sherlock update:", err)
os.Exit(exitGeneric)
}
fmt.Printf("Updated to %s.\n", latest)
}
// updateSourceCheckout ensures $XDG_CACHE_HOME/sherlock/src is a clone
// of the sherlock repo checked out at tag, cloning on first use and
// fetching otherwise. Returns the checkout path.
func updateSourceCheckout(tag string) (string, error) {
cache, err := xdg.CacheHome()
if err != nil {
return "", err
}
src := filepath.Join(cache, "sherlock", "src")
if _, err := os.Stat(filepath.Join(src, ".git")); err != nil {
if err := os.MkdirAll(filepath.Dir(src), 0o700); err != nil {
return "", fmt.Errorf("mkdir cache: %w", err)
}
_ = os.RemoveAll(src) // clear any partial/non-git leftovers
if err := run("", "git", "clone", "--quiet", selfupdate.RepoURL(), src); err != nil {
return "", fmt.Errorf("clone %s: %w", selfupdate.RepoURL(), err)
}
} else {
if err := run(src, "git", "fetch", "--quiet", "--tags", "origin"); err != nil {
return "", fmt.Errorf("git fetch: %w", err)
}
}
if err := run(src, "git", "checkout", "--quiet", "--force", tag); err != nil {
return "", fmt.Errorf("git checkout %s: %w", tag, err)
}
return src, nil
}
// goInstallAll runs `go install ./cmd/...` from src, baking version into
// every binary's main.Version.
func goInstallAll(src, version string) error {
return run(src, "go", "install",
"-ldflags", "-X main.Version="+version,
"./cmd/...")
}
// run executes name with args in dir (cwd if empty), streaming output
// to the user's stderr/stdout.
func run(dir, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stderr // build chatter goes to stderr, not stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+1 -1
View File
@@ -46,7 +46,7 @@ Drop a new file in `internal/agent/`:
// internal/agent/aider.go
package agent
import "gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
import "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
func init() { Register(&aider{}) }
+9 -5
View File
@@ -16,16 +16,19 @@ No top-level `pkg/` until we have an external consumer.
## Go
- Module path: `gitea.alexandru.macocian.me/Charlie/sherlock`.
- Module path: `gitea.alexandru.macocian.me/amacocian/sherlock`.
- Target Go toolchain: `go 1.25` (pinned via `go.mod`'s `go` directive; CI installs the matching minor).
- One `package main` per `cmd/<binary>/`. No multiple-`main`-files trickery.
- Every other package has a top-of-file `Package <name> ...` doc comment (in the leading `.go` source file; we drop standalone `doc.go` files once a real source file exists).
- Third-party deps are added with a one-line justification. The current set:
- `github.com/zalando/go-keyring` — OS keyring for token persistence (Decision #8).
- `github.com/BurntSushi/toml` — parse `services.d/*.toml` (Phase 2+).
- `github.com/zalando/go-keyring` — OS keyring for token persistence.
- `github.com/BurntSushi/toml` — parse the operator config (`config.toml`).
- `github.com/coreos/go-oidc/v3` — OIDC discovery + ID-token verification against Authentik.
- `golang.org/x/oauth2` — auth-code + PKCE + refresh against Authentik's token endpoint.
- `golang.org/x/sync``singleflight` for collapsing concurrent token refreshes.
- `golang.org/x/mod``semver` for version comparison in self-update.
- `github.com/grafana/mcp-grafana` + `github.com/mark3labs/mcp-go` — upstream Grafana MCP tool set, served in-process by `cmd/grafana-mcp`.
- `github.com/modelcontextprotocol/go-sdk` — MCP server SDK for the gitea/gssh MCPs.
- `github.com/coder/websocket` — gssh exec WebSocket client.
## Docs
@@ -54,7 +57,8 @@ No top-level `pkg/` until we have an external consumer.
## CI
- `.gitea/workflows/ci.yaml` runs on every push + PR. It runs `gofmt`, `go vet`, `errcheck`, `staticcheck`, `go test -race`, and `go build`. No other gates.
- No deploy pipeline — sherlock is operator-installed via `go install`. Release artifacts (binaries to the gitea registry / releases) land in Phase 5 if at all.
- `.gitea/workflows/release.yaml` runs on push to `main`. It reads the root `VERSION` file and pushes the tag `vVERSION` if it doesn't already exist. Cutting a release is a one-line edit to `VERSION`. See [versioning.md](versioning.md).
- Sherlock is operator-installed via `install.sh` (which clones + `go install`s) and self-updates via `sherlock update`. No host-deploy pipeline.
## Security
+26 -7
View File
@@ -5,25 +5,30 @@ Sherlock installs from a clone of this repo with a single script.
## Quick start
```bash
git clone https://gitea.alexandru.macocian.me/Charlie/sherlock.git
git clone https://gitea.alexandru.macocian.me/amacocian/sherlock.git
cd sherlock
./install.sh
```
The script:
1. **Seeds the config.** Copies [`config.example.toml`](../config.example.toml)
1. **Syncs the source.** Clones (or updates) the sherlock repo under
`${XDG_CACHE_HOME:-~/.cache}/sherlock/src` and checks out the latest
release tag, so the install is reproducible and shares one checkout
with `sherlock update`. (Use `--local` to build from the checkout
you ran the script from instead — handy for development.)
2. **Seeds the config.** Copies [`config.example.toml`](../config.example.toml)
to your config path (`~/.config/sherlock/config.toml` by default) — but
only if you don't already have one, so re-runs never clobber filled-in
values.
2. **Opens it in your editor** (`$VISUAL` / `$EDITOR`, falling back to
3. **Opens it in your editor** (`$VISUAL` / `$EDITOR`, falling back to
`nano`/`vi`) so you can fill in the Authentik issuer/client IDs and
each service's base URL. See [configuration.md](configuration.md) for
the schema.
3. **Builds and installs** `sherlock` and every MCP binary
(`gitea-mcp`, `grafana-mcp`, `gssh-mcp`) with `go install ./cmd/...`
once you save and close the editor.
4. **Installs shell completions** where it can — currently a `fish`
4. **Builds and installs** `sherlock` and every MCP binary
(`gitea-mcp`, `grafana-mcp`, `gssh-mcp`) with `go install ./cmd/...`,
baking in the release version.
5. **Installs shell completions** where it can — currently a `fish`
completion into `~/.config/fish/completions/sherlock.fish`, but only
if you already have a fish config directory (it won't create one for
non-fish users).
@@ -51,6 +56,7 @@ sherlock copilot
|---|---|
| `-y`, `--yes` | Skip the editor step (config is already filled in). |
| `--config-only` | Seed/edit the config but don't build or install. |
| `--local` | Build from the current checkout instead of cloning to the cache (development). |
| `-h`, `--help` | Show usage. |
## Env overrides
@@ -58,8 +64,21 @@ sherlock copilot
| Variable | Effect |
|---|---|
| `SHERLOCK_CONFIG` | Exact config path to write (else `$XDG_CONFIG_HOME` / `~/.config`). |
| `SHERLOCK_UPDATE_REPO_URL` | Clone URL to build from (else the public sherlock repo). |
| `VISUAL` / `EDITOR` | Editor to open. |
## Updating
Sherlock updates itself in place:
```bash
sherlock update # if a newer release exists
sherlock update --force # reinstall the latest regardless
```
Release builds also print a one-line hint when a newer version is
available. See [versioning.md](versioning.md).
## Re-running
`./install.sh` is safe to re-run: it edits your existing config in place
+69
View File
@@ -0,0 +1,69 @@
# Versioning & self-update
Sherlock is versioned by git tags on the public repo and can update
itself in place.
## Version scheme
- The source of truth is the **`VERSION`** file at the repo root, holding
a full semver string (e.g. `0.1.0`).
- On every push to `main`, the [release workflow](../.gitea/workflows/release.yaml)
reads `VERSION` and, **if the matching tag `vVERSION` does not already
exist**, creates and pushes it at that commit. Cutting a release is a
one-line edit to `VERSION` — no manual tagging.
- Binaries bake their version in at build time via
`-ldflags "-X main.Version=<v>"`. A build without that flag reports the
sentinel `0.0.0-dev` and is treated as "not a release".
## How a build gets its version
| Build path | Version baked |
|---|---|
| `install.sh` (default) | the latest `vX.Y.Z` tag in the cache checkout, or the `VERSION` file if there are no tags yet |
| `install.sh --local` | the `VERSION` file in your working tree |
| `sherlock update` | the latest published tag |
| plain `go install ./cmd/...` | `0.0.0-dev` (no `-ldflags`) |
## Update checking
`internal/selfupdate` reads the repo's tags through Gitea's public REST
API (no auth) and compares the highest `vX.Y.Z` against the running
binary using `golang.org/x/mod/semver`.
- **`sherlock version`** prints the running version and, for release
builds, appends a one-line hint if a newer tag exists.
- **Agent launches** (`sherlock copilot`, …) do a bounded, best-effort
check just before handing off to the agent and print the same hint.
It never blocks for more than ~1.5s and is silent on dev builds,
offline, or when `SHERLOCK_NO_UPDATE_CHECK` is set.
The check is intentionally **uncached** — it runs each time — but is
always bounded and non-fatal, so it can't break or noticeably delay a
command.
## Updating
```bash
sherlock update # update to the latest release if newer
sherlock update --force # reinstall the latest even if not newer
```
`update`:
1. Resolves the latest tag from the tags API.
2. Clones (first time) or fetches the source under
`${XDG_CACHE_HOME:-~/.cache}/sherlock/src` and checks out that tag.
3. Runs `go install -ldflags "-X main.Version=<tag>" ./cmd/...`, which
reinstalls `sherlock` and every MCP binary with the version baked in.
It requires `git` and the Go toolchain on `PATH`. The same cache
checkout is what `install.sh` builds from, so the two share one source
of truth.
## Environment overrides
| Variable | Effect |
|---|---|
| `SHERLOCK_NO_UPDATE_CHECK` | Disable the passive update hint. |
| `SHERLOCK_UPDATE_TAGS_URL` | Override the tags API URL (forks, mirrors, tests). |
| `SHERLOCK_UPDATE_REPO_URL` | Override the clone URL used by `update` and `install.sh`. |
Executable
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -1,8 +1,9 @@
module gitea.alexandru.macocian.me/Charlie/sherlock
module gitea.alexandru.macocian.me/amacocian/sherlock
go 1.26.3
require (
github.com/BurntSushi/toml v1.6.0
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
@@ -10,12 +11,12 @@ require (
github.com/mark3labs/mcp-go v0.46.0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/zalando/go-keyring v0.2.8
golang.org/x/mod v0.35.0
golang.org/x/oauth2 v0.36.0
)
require (
connectrpc.com/connect v1.19.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/PaesslerAG/gval v1.2.4 // indirect
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
github.com/apache/arrow-go/v18 v18.5.1 // indirect
@@ -157,7 +158,6 @@ require (
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
+65 -23
View File
@@ -2,42 +2,50 @@
#
# sherlock installer.
#
# Run from a clone of this repo:
#
# ./install.sh
#
# It:
# 1. copies config.example.toml to your sherlock config path
# 1. clones (or updates) the sherlock source under
# ${XDG_CACHE_HOME:-~/.cache}/sherlock/src and checks out the latest
# release tag, so installs are reproducible and share the same
# checkout as `sherlock update`,
# 2. copies config.example.toml to your sherlock config path
# (default ~/.config/sherlock/config.toml) if you don't have one yet,
# 2. opens it in your editor so you can fill in your deployment values,
# 3. on save, `go install`s sherlock and every MCP binary.
# 3. opens it in your editor so you can fill in your deployment values,
# 4. on save, `go install`s sherlock and every MCP binary (with the
# release version baked in), plus the fish completion if you use fish.
#
# Flags:
# -y, --yes skip the editor step (config is already filled in)
# --config-only set up/edit the config but do NOT build/install
# --local build from THIS checkout instead of cloning to the
# cache (for development)
# -h, --help show this help
#
# Env overrides:
# SHERLOCK_CONFIG exact config path to write (else XDG / ~/.config)
# VISUAL / EDITOR editor to open (falls back to nano, then vi)
# SHERLOCK_CONFIG exact config path (else XDG / ~/.config)
# SHERLOCK_UPDATE_REPO_URL clone URL (else the public sherlock repo)
# VISUAL / EDITOR editor to open (falls back to nano, then vi)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXAMPLE="$SCRIPT_DIR/config.example.toml"
REPO_URL="${SHERLOCK_UPDATE_REPO_URL:-https://gitea.alexandru.macocian.me/amacocian/sherlock}"
CACHE_SRC="${XDG_CACHE_HOME:-$HOME/.cache}/sherlock/src"
# Every cmd/<dir> that ships a binary. Keep in sync with cmd/.
BINARIES=(sherlock gitea-mcp grafana-mcp gssh-mcp)
SKIP_EDITOR=0
CONFIG_ONLY=0
LOCAL=0
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
usage() {
sed -n '3,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
sed -n '3,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
@@ -45,13 +53,52 @@ while [ $# -gt 0 ]; do
case "$1" in
-y|--yes) SKIP_EDITOR=1 ;;
--config-only) CONFIG_ONLY=1 ;;
--local) LOCAL=1 ;;
-h|--help) usage 0 ;;
*) warn "unknown argument: $1"; usage 1 ;;
esac
shift
done
# ── Resolve the config path (mirrors internal/config.DefaultPath) ─────
# ── Toolchain checks ─────────────────────────────────────────────────
command -v go >/dev/null 2>&1 || die "the Go toolchain is required but 'go' was not found on PATH. Install Go (https://go.dev/dl/), then re-run."
if [ "$LOCAL" -eq 0 ]; then
command -v git >/dev/null 2>&1 || die "git is required to clone the sherlock source but was not found on PATH."
fi
# ── 1. Resolve the build source (cache clone at latest tag, or local) ─
VERSION="0.0.0-dev"
if [ "$LOCAL" -eq 1 ]; then
SOURCE="$SCRIPT_DIR"
[ -f "$SOURCE/VERSION" ] && VERSION="$(tr -d ' \t\r\n' < "$SOURCE/VERSION")"
info "Building from local checkout: $SOURCE (version $VERSION)"
else
SOURCE="$CACHE_SRC"
if [ -d "$SOURCE/.git" ]; then
info "Updating cached source at $SOURCE"
git -C "$SOURCE" fetch --quiet --tags origin
else
info "Cloning $REPO_URL into $SOURCE"
mkdir -p "$(dirname "$SOURCE")"
rm -rf "$SOURCE"
git clone --quiet "$REPO_URL" "$SOURCE"
fi
# Latest vX.Y.Z tag, if any; otherwise stay on the default branch.
tag="$(git -C "$SOURCE" tag -l 'v*' --sort=-v:refname | head -n1)"
if [ -n "$tag" ]; then
git -C "$SOURCE" checkout --quiet --force "$tag"
VERSION="$tag"
info "Checked out release $tag"
else
[ -f "$SOURCE/VERSION" ] && VERSION="$(tr -d ' \t\r\n' < "$SOURCE/VERSION")"
warn "No release tags found yet — building the default branch as $VERSION."
fi
fi
EXAMPLE="$SOURCE/config.example.toml"
[ -f "$EXAMPLE" ] || die "config.example.toml not found in the source ($EXAMPLE)."
# ── 2. Resolve the config path (mirrors internal/config.DefaultPath) ──
config_path() {
if [ -n "${SHERLOCK_CONFIG:-}" ]; then
printf '%s\n' "$SHERLOCK_CONFIG"
@@ -61,12 +108,9 @@ config_path() {
printf '%s/.config/sherlock/config.toml\n' "$HOME"
fi
}
CONFIG="$(config_path)"
# ── 1. Seed the config from the example (never clobber a filled one) ──
[ -f "$EXAMPLE" ] || die "config.example.toml not found next to this script ($EXAMPLE). Run install.sh from inside the repo."
# Seed the config from the example (never clobber a filled one).
if [ -f "$CONFIG" ]; then
info "Existing config found at $CONFIG — editing it in place (not overwriting)."
else
@@ -76,7 +120,7 @@ else
chmod 600 "$CONFIG"
fi
# ── 2. Open it in the editor and wait for the user to finish ─────────
# ── 3. Open it in the editor and wait for the user to finish ─────────
pick_editor() {
if [ -n "${VISUAL:-}" ]; then printf '%s\n' "$VISUAL"; return; fi
if [ -n "${EDITOR:-}" ]; then printf '%s\n' "$EDITOR"; return; fi
@@ -106,28 +150,26 @@ if [ "$CONFIG_ONLY" -eq 1 ]; then
exit 0
fi
# ── 3. Build + install every binary from this clone ──────────────────
command -v go >/dev/null 2>&1 || die "the Go toolchain is required but 'go' was not found on PATH. Install Go, then re-run."
info "Installing binaries: ${BINARIES[*]}"
# ── 4. Build + install every binary from the source ──────────────────
info "Installing binaries (version $VERSION): ${BINARIES[*]}"
pkgs=()
for b in "${BINARIES[@]}"; do
pkgs+=("./cmd/$b")
done
( cd "$SCRIPT_DIR" && go install "${pkgs[@]}" )
( cd "$SOURCE" && go install -ldflags "-X main.Version=$VERSION" "${pkgs[@]}" )
# ── 3b. Install the fish completion if fish is configured ────────────
# ── 4b. Install the fish completion if fish is configured ────────────
# Only when a fish config dir already exists — we don't create one for
# users who don't run fish.
fish_config_dir="${__fish_config_dir:-${XDG_CONFIG_HOME:-$HOME/.config}/fish}"
completion_src="$SCRIPT_DIR/completions/sherlock.fish"
completion_src="$SOURCE/completions/sherlock.fish"
if [ -d "$fish_config_dir" ] && [ -f "$completion_src" ]; then
mkdir -p "$fish_config_dir/completions"
cp "$completion_src" "$fish_config_dir/completions/sherlock.fish"
info "Installed fish completion to $fish_config_dir/completions/sherlock.fish"
fi
# ── 4. Report where they landed ──────────────────────────────────────
# ── 5. Report where they landed ──────────────────────────────────────
bindir="$(go env GOBIN)"
[ -n "$bindir" ] || bindir="$(go env GOPATH)/bin"
info "Installed to $bindir"
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"fmt"
"sort"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
)
// Context is everything the CLI hands an agent at spawn time. Kept
+1 -1
View File
@@ -1,7 +1,7 @@
package agent
import (
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
)
func init() { Register(&claude{}) }
+1 -1
View File
@@ -1,7 +1,7 @@
package agent
import (
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/mcp"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
)
func init() { Register(&copilot{}) }
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"context"
"errors"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// EnsureOptions tunes Ensure for tests and for MCPs that want to
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"testing"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
)
func TestEnsure_FastPath_NoOAuthFlow(t *testing.T) {
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// ErrNotConfigured is returned by Login when Config.Configured() is
+2 -2
View File
@@ -22,8 +22,8 @@ import (
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
)
// stubAuthentik signs ES256 ID tokens and returns them through a real
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// RefreshSkew is how much headroom EnsureFresh leaves before the ID
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"sync/atomic"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// minRenewInterval bounds how often Run attempts a renewal, even when
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring/fake"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
)
func TestNewTokenSource_RequiresCallback(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"slices"
"sync"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// New returns a fresh in-memory Store.
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"slices"
"testing"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
func TestFake_RoundTrip(t *testing.T) {
+1 -1
View File
@@ -22,7 +22,7 @@ import (
"os"
"path/filepath"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/xdg"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
)
// Server is one stdio MCP server entry.
+146
View File
@@ -0,0 +1,146 @@
// Package selfupdate checks whether a newer sherlock release is
// available and reports it. The source of truth is the git tags on the
// public sherlock repo, read through Gitea's REST API (no auth — the
// repo is public).
//
// Versions are git tags of the form `vMAJOR.MINOR.PATCH`. The binary's
// own version is baked in at build time via -ldflags "-X main.Version".
// A build that still reads the sentinel dev version ("0.0.0-dev") is
// treated as "not a release" and never nags about updates.
package selfupdate
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"golang.org/x/mod/semver"
)
// DevVersion is the sentinel main.Version of an unversioned local
// build. CheckForUpdate is a no-op for it.
const DevVersion = "0.0.0-dev"
// defaultTagsAPI is the Gitea tags endpoint for the public sherlock
// repo. Overridable via $SHERLOCK_UPDATE_TAGS_URL for forks/tests.
const defaultTagsAPI = "https://gitea.alexandru.macocian.me/api/v1/repos/amacocian/sherlock/tags"
// DefaultRepoURL is the public clone URL the self-updater builds from.
// Overridable via $SHERLOCK_UPDATE_REPO_URL.
const DefaultRepoURL = "https://gitea.alexandru.macocian.me/amacocian/sherlock"
// EnvRepoURL overrides the clone URL (forks, tests, mirrors).
const EnvRepoURL = "SHERLOCK_UPDATE_REPO_URL"
// RepoURL returns the configured clone URL.
func RepoURL() string {
if u := os.Getenv(EnvRepoURL); u != "" {
return u
}
return DefaultRepoURL
}
// EnvTagsURL overrides the tags API URL (forks, tests, air-gapped).
const EnvTagsURL = "SHERLOCK_UPDATE_TAGS_URL"
// EnvDisable, when set to a non-empty value, disables the passive
// update check entirely.
const EnvDisable = "SHERLOCK_NO_UPDATE_CHECK"
// tagsURL returns the configured tags endpoint.
func tagsURL() string {
if u := os.Getenv(EnvTagsURL); u != "" {
return u
}
return defaultTagsAPI
}
// normalize coerces a raw version string into the canonical, comparable
// `vX.Y.Z` form semver expects. Accepts both "0.1.0" and "v0.1.0".
// Returns "" if the input isn't valid semver.
func normalize(v string) string {
if v == "" {
return ""
}
if v[0] != 'v' {
v = "v" + v
}
if !semver.IsValid(v) {
return ""
}
return v
}
type tag struct {
Name string `json:"name"`
}
// LatestTag fetches the highest semver tag from the repo. It returns
// the canonical `vX.Y.Z` string, or "" with a nil error when the repo
// has no valid version tags yet.
func LatestTag(ctx context.Context, client *http.Client) (string, error) {
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tagsURL(), nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("selfupdate: tags API returned HTTP %d", resp.StatusCode)
}
var tags []tag
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return "", fmt.Errorf("selfupdate: decode tags: %w", err)
}
latest := ""
for _, t := range tags {
v := normalize(t.Name)
if v == "" {
continue
}
if latest == "" || semver.Compare(v, latest) > 0 {
latest = v
}
}
return latest, nil
}
// Result is the outcome of a CheckForUpdate call.
type Result struct {
Current string // the running binary's version, normalized (e.g. "v0.1.0")
Latest string // the latest published tag, normalized (e.g. "v0.2.0")
Available bool // true iff Latest is a strictly newer valid release
}
// CheckForUpdate compares the running version against the latest tag.
// It never errors out the caller's flow for "expected" cases (dev
// build, no tags, network down): those yield Available=false. A non-nil
// error is returned only for genuinely unexpected failures the caller
// may want to surface in an explicit `sherlock update`.
func CheckForUpdate(ctx context.Context, current string, client *http.Client) (Result, error) {
res := Result{Current: normalize(current)}
if current == DevVersion || res.Current == "" {
// Unversioned/dev build — don't nag.
return res, nil
}
latest, err := LatestTag(ctx, client)
if err != nil {
return res, err
}
res.Latest = latest
if latest == "" {
return res, nil
}
res.Available = semver.Compare(latest, res.Current) > 0
return res, nil
}
+110
View File
@@ -0,0 +1,110 @@
package selfupdate
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func tagsServer(t *testing.T, body string, status int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func TestLatestTag_PicksHighestSemver(t *testing.T) {
srv := tagsServer(t, `[{"name":"v0.1.0"},{"name":"v0.10.0"},{"name":"v0.2.0"},{"name":"not-a-tag"}]`, 200)
t.Setenv(EnvTagsURL, srv.URL)
got, err := LatestTag(context.Background(), nil)
if err != nil {
t.Fatalf("LatestTag: %v", err)
}
if got != "v0.10.0" {
t.Fatalf("latest = %q, want v0.10.0", got)
}
}
func TestLatestTag_NoValidTags(t *testing.T) {
srv := tagsServer(t, `[{"name":"nightly"},{"name":"latest"}]`, 200)
t.Setenv(EnvTagsURL, srv.URL)
got, err := LatestTag(context.Background(), nil)
if err != nil {
t.Fatalf("LatestTag: %v", err)
}
if got != "" {
t.Fatalf("latest = %q, want empty", got)
}
}
func TestCheckForUpdate_Available(t *testing.T) {
srv := tagsServer(t, `[{"name":"v0.2.0"}]`, 200)
t.Setenv(EnvTagsURL, srv.URL)
res, err := CheckForUpdate(context.Background(), "v0.1.0", nil)
if err != nil {
t.Fatalf("CheckForUpdate: %v", err)
}
if !res.Available {
t.Fatalf("expected update available, got %+v", res)
}
if res.Latest != "v0.2.0" || res.Current != "v0.1.0" {
t.Fatalf("res = %+v", res)
}
}
func TestCheckForUpdate_UpToDate(t *testing.T) {
srv := tagsServer(t, `[{"name":"v0.2.0"}]`, 200)
t.Setenv(EnvTagsURL, srv.URL)
res, err := CheckForUpdate(context.Background(), "0.2.0", nil) // bare version accepted
if err != nil {
t.Fatalf("CheckForUpdate: %v", err)
}
if res.Available {
t.Fatalf("expected up to date, got %+v", res)
}
}
func TestCheckForUpdate_NewerThanRemote(t *testing.T) {
srv := tagsServer(t, `[{"name":"v0.1.0"}]`, 200)
t.Setenv(EnvTagsURL, srv.URL)
res, err := CheckForUpdate(context.Background(), "v0.2.0", nil)
if err != nil {
t.Fatalf("CheckForUpdate: %v", err)
}
if res.Available {
t.Fatalf("local ahead of remote should not report available, got %+v", res)
}
}
func TestCheckForUpdate_DevBuildSkips(t *testing.T) {
// No server hit expected; point at an unreachable URL to prove it's
// never called for a dev build.
t.Setenv(EnvTagsURL, "http://127.0.0.1:1/never")
res, err := CheckForUpdate(context.Background(), DevVersion, nil)
if err != nil {
t.Fatalf("dev build should not error: %v", err)
}
if res.Available {
t.Fatal("dev build should never report an update")
}
}
func TestNormalize(t *testing.T) {
cases := map[string]string{
"0.1.0": "v0.1.0",
"v0.1.0": "v0.1.0",
"": "",
"garbage": "",
"v1": "v1", // semver.IsValid accepts v1
}
for in, want := range cases {
if got := normalize(in); got != want {
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
}
}
}
+14
View File
@@ -45,6 +45,20 @@ func ConfigHome() (string, error) {
return ensureDir(filepath.Join(home, ".config"), 0o700)
}
// CacheHome returns $XDG_CACHE_HOME, falling back to $HOME/.cache. The
// path is created if missing. Used for the self-update source checkout
// under <CacheHome>/sherlock.
func CacheHome() (string, error) {
if d := os.Getenv("XDG_CACHE_HOME"); d != "" {
return ensureDir(d, 0o700)
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("xdg: resolve home: %w", err)
}
return ensureDir(filepath.Join(home, ".cache"), 0o700)
}
// RefreshLockPath returns the canonical flock path used by EnsureFresh
// to serialise concurrent Authentik refresh attempts across sherlock
// invocations.