# Seamless Makefile. Go 1.26+, no CGO. BINARY := seamlessd CLI := seam BIN_DIR := bin PKG := ./... GO ?= go # golangci-lint major line. .golangci.yml declares `version: "2"`, and the two # schema generations are mutually unreadable, so a v1 binary does not lint badly # here -- it refuses to start. The `lint` target checks for that rather than # letting the raw schema error surface. CI pins this same version; bump both. GOLANGCI_VERSION ?= v2.14.0 GOLANGCI_INSTALL := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION) # Build metadata linked into the daemon (surfaced in /healthz, the MCP handshake, # and the startup log so a stale running daemon is visible). `make build` stamps # them, and any stamp wins wholesale; a build with no ldflags (`go build`, # `go install ...@vX.Y.Z`) falls back to the binary's Go build info at startup # (cmd/seamlessd/buildinfo.go). # # VERSION derives from the git tag exactly the way goreleaser does (nearest v* # tag, leading v stripped) so `make install` and a released binary report the # same number -- the tag is the single source of truth, never a hand-edited # constant. With no reachable tag it falls back to the dev sentinel (a build # past a tag reports that nearest tag); the +COMMIT suffix buildVersion() # appends still pins the exact build. COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILDDATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true) VERSION := $(if $(VERSION),$(VERSION),0.0.0-dev) LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILDDATE) # launchd service (macOS user LaunchAgent). LEGACY_SVC_LABEL is the label before # the rename (plan:move-to-arctop); _reload-service retires it. UID := $(shell id -u) SVC_LABEL := com.arctop.seamless SVC_TEMPLATE := deploy/launchd/$(SVC_LABEL).plist SVC_PLIST := $(HOME)/Library/LaunchAgents/$(SVC_LABEL).plist LEGACY_SVC_LABEL := org.thereisnospoon.seamless LEGACY_SVC_PLIST := $(HOME)/Library/LaunchAgents/$(LEGACY_SVC_LABEL).plist SVC_LOG := $(HOME)/.seamless/seamlessd.log # Install locations: stable paths, independent of this working tree. `make # install` snapshots the binaries and config here and points launchd + the # selected clients' hooks/MCP registrations at the copies, so `make build`, a # branch switch, or a moved repo # never change what the live daemon and every agent's hooks execute. # Override the prefix with `make install PREFIX=/custom`. PREFIX ?= $(HOME)/.local PREFIX_BIN := $(PREFIX)/bin CONFIG_DIR := $(HOME)/.config/seamless CONFIG := $(CONFIG_DIR)/seamless.yaml # detect = the agent clients present on this machine (codex CLI or ~/.codex, # claude CLI or ~/.claude, the Claude app bundle or its desktop config), # resolved by `seamlessd install-hooks` -- the same selection the curl # installer makes. When nothing is found, install-hooks asks on a terminal # (defaulting to no) and errors otherwise; there is no silent Claude Code # fallback. Pass CLIENT=claude|codex|claude-desktop|all (or a comma list) to # choose explicitly. CLIENT ?= detect # gofmt over TRACKED files only. The go tool's ./... pattern skips dot-dirs, so # build/vet/test/lint never see .claude/worktrees/ (other agents' checkouts of # this same repo). gofmt takes paths, not packages, and walks the filesystem # raw: `gofmt -l .` reports their drift as ours, and `gofmt -w .` rewrites # their files underneath them mid-edit. Always go through this list. GOFILES := git ls-files '*.go' # The bind address lives in the config (`addr:`) -- that is the single source of # truth, because the daemon reads it there and nothing else can override it # without becoming a second one. So this Makefile *reads* the port, never owns # it: change `addr:` in the config, `make install`, and the health poll and help # text follow. Deliberately not a make variable: baking SEAMLESS_ADDR into the # plist would let env silently beat the config file, so editing `addr:` would # appear to do nothing. # # Recursive (=), not :=, so it is read at use time -- $(CONFIG) may not exist # until _seed-config runs during the first install. DEFAULT_ADDR mirrors # config.Defaults() for the pre-install case. # (No `#` in the character class: make strips from an unescaped `#` to end of # line even inside $(shell ...), which silently truncates the call. Excluding # whitespace already drops any trailing YAML comment, since YAML requires a # space before `#`.) DEFAULT_ADDR := 127.0.0.1:8081 CONFIG_ADDR = $(shell sed -n 's/^addr:[[:space:]]*"\{0,1\}\([^"[:space:]]*\).*/\1/p' $(CONFIG) 2>/dev/null | head -1) ADDR = $(or $(CONFIG_ADDR),$(DEFAULT_ADDR)) # The other two keys this Makefile reads, and for the same reason: `role: # client` means this machine runs NO daemon of its own -- `seamlessd serve` # refuses outright under it -- so the plist, the service and the local health # poll all have to fall away, and the install's health question becomes the # SERVER's /healthz at `server_url:`. Same recursive (=) treatment as ADDR, read # at use time because _seed-config may create the file during this very run. # # IS_CLIENT is the flag every branch tests (non-empty = client), so the literal # "client" is compared in exactly one place here -- the Makefile's version of # the Go side's "read it through IsClient, never by comparing this string". CONFIG_ROLE = $(shell sed -n 's/^role:[[:space:]]*"\{0,1\}\([^"[:space:]]*\).*/\1/p' $(CONFIG) 2>/dev/null | head -1) IS_CLIENT = $(filter client,$(CONFIG_ROLE)) CONFIG_SERVER_URL = $(shell sed -n 's/^server_url:[[:space:]]*"\{0,1\}\([^"[:space:]]*\).*/\1/p' $(CONFIG) 2>/dev/null | head -1) # Documentation site (cmd/docsgen). DOCS_OUT is committed; `make check` fails if # it drifts from DOCS_SRC. SITE_ROOT is the directory GitHub Pages serves; # docsgen additionally writes the crawler files there ($(SITE_FILES), # overwriting those named files only, never deleting), and docs-check gates # them all. DOCS_SRC := docs-src DOCS_OUT := docs/docs SITE_ROOT := docs SITE_FILES := sitemap.xml robots.txt llms.txt llms-full.txt index.md auth.md \ .well-known/api-catalog .well-known/mcp/server-card.json \ .well-known/agent-card.json \ .well-known/agent-skills/index.json \ .well-known/agent-skills/seam-onboard/SKILL.md \ .well-known/agent-skills/seam-research/SKILL.md DOCS_ADDR ?= 127.0.0.1:8899 # The one-command installer (curl -fsSL https://thereisnospoon.org/install | sh). # It lives at the site root because GitHub Pages serves docs/ verbatim, so this # path IS the published URL -- there is no copy to keep in step. docsgen does # not touch it: beyond $(DOCS_OUT) it owns only the two named crawler files. # PS_INSTALLER is the Windows companion, served the same way at /install.ps1. INSTALLER := docs/install PS_INSTALLER := docs/install.ps1 .PHONY: help build test test-race bench seambench lint vet cross-check vulncheck fmt fmt-check check check-fast tidy run doctor console console-chrome \ docs docs-check docs-serve changelog installer-check site-check site-stamp indexnow metrics release-snapshot install-git-hooks uninstall-git-hooks \ install uninstall update _check-takeover _seed-config _install-service _reload-service \ _check-health _wait-healthy _check-server start stop restart status \ logs install-onboard-skill uninstall-onboard-skill \ install-research-skill uninstall-research-skill clean help: @echo "Seamless targets:" @echo " build compile ./bin/$(BINARY) and ./bin/$(CLI) (touches nothing live)" @echo " test run unit tests" @echo " test-race run unit tests with the race detector" @echo " bench run hot-path Go micro-benchmarks, ns/op (BENCHTIME=1x for a quick smoke run)" @echo " seambench the AGENT-SCENARIO benchmark: runs a real agent over seeded fixtures and" @echo " prints with-vs-without uplift. SPENDS REAL API TOKENS; needs a 'claude'" @echo " binary + credentials. MANUAL ONLY -- not in check, CI, or any schedule." @echo " Unrelated to 'bench' above (see cmd/seambench/README.md)" @echo " check the full gate: build + vet + cross-check + fmt + docs + installer + site + lint + vulncheck + test-race" @echo " check-fast the pre-commit subset: same minus build and test-race" @echo " lint run golangci-lint" @echo " vet run go vet" @echo " cross-check go vet for $(CROSS_TARGETS) (part of check; CI tests only three packages on macOS/Windows)" @echo " vulncheck run govulncheck against the vuln DB (part of check; needs network)" @echo " fmt gofmt tracked files" @echo " fmt-check fail if tracked files have gofmt drift" @echo " docs regenerate the docs site (docs-src/ -> docs/docs/, committed)" @echo " docs-check fail if the committed docs site is stale (part of check)" @echo " docs-serve regenerate and serve the site at $(DOCS_ADDR)" @echo " changelog regenerate docs-src/changelog.md from the git tags + re-render" @echo " (run at release time, after tagging; output is committed)" @echo " installer-check parse-check $(INSTALLER) (part of check)" @echo " site-check fail if the landing page drifts from the installer/CLI (part of check)" @echo " site-stamp restamp the landing page's static asset ?v= cache-busters" @echo " indexnow submit the sitemap's URLs to the IndexNow engines (manual," @echo " run after a deploy is live; DRY=1 previews the payload)" @echo " metrics print GitHub stars + release download counts (needs gh)" @echo " release-snapshot dry-run the release build into dist/ (needs goreleaser)" @echo " approver macOS only: build the opt-in Touch ID approver app into bin/approver/" @echo " approver-test run the approver's Swift unit tests (no keychain, no prompt)" @echo " tidy go mod tidy" @echo " run build and start the server in the foreground ($(ADDR))" @echo " doctor build and run config + DB self-checks" @echo " console open the console in a browser, pre-authenticated" @echo " console-chrome same, but force Google Chrome (for agent testing)" @echo " Deploy (snapshots to stable paths; this is also the iterate loop):" @echo " install build + copy bin/config to $(PREFIX_BIN) + $(CONFIG_DIR)," @echo " point the service, hooks, MCP + skills there, and restart" @echo " (CLIENT=claude|codex|claude-desktop|all|detect, or a comma" @echo " list, selects the wired agent" @echo " client; default detect = the clients on this machine)" @echo " under 'role: client' the service is skipped and the" @echo " SERVER's /healthz is polled -- this machine runs no daemon;" @echo " refuses on a machine the curl installer set up (FORCE=1" @echo " takes it over and ends its automatic updates)" @echo " uninstall remove service, hooks, MCP, skills + binaries (config/data kept;" @echo " PURGE=1 also deletes config + ~/.seamless)" @echo " update upgrade in place to the latest release (CHECK=1 only reports)" @echo " start start the installed service (launchd/systemd/task)" @echo " stop stop the installed service" @echo " restart restart the installed service in place" @echo " status print the installed service's state" @echo " logs follow the service log ($(SVC_LOG))" @echo " install-git-hooks enable .githooks/ (pre-commit runs check-fast)" @echo " uninstall-git-hooks disable .githooks/" @echo " install-onboard-skill install seam-onboard from the checkout (CLIENT=claude|codex|all|detect;" @echo " 'make install' already installs the embedded copy)" @echo " uninstall-onboard-skill remove seam-onboard for CLIENT (default: detect)" @echo " install-research-skill install seam-research from the checkout (CLIENT=claude|codex|all|detect)" @echo " uninstall-research-skill remove seam-research for CLIENT (default: detect)" @echo " clean remove build artifacts" build: $(GO) build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) ./cmd/seamlessd $(GO) build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(CLI) ./cmd/seam test: $(GO) test $(PKG) # go test's default limit is 10 minutes per package; internal/console alone has # taken about 9 under the race detector in CI. test-race: $(GO) test -race -timeout 20m $(PKG) # The defaults row of plan seamless-sealed-unlock: with peer_auth and # isolation.requests off, every agent surface must match the golden written on # main (cmd/seamlessd/testdata/defaults/). Not part of check: it is the merge # gate for branch locked-projects, and legitimate changes move it. .PHONY: defaults-golden defaults-golden: $(GO) test -tags defaultsgolden ./cmd/seamlessd -run TestDefaults -count=1 # Hot-path baseline benchmarks (recall, briefing, prompt matcher, event # append + SSE fan-out). -run '^$' skips unit tests so only benchmarks run. # Override BENCHTIME (e.g. BENCHTIME=1x) for a quick compile/smoke check. BENCHTIME ?= 1s bench: $(GO) test -run '^$$' -bench . -benchmem -benchtime $(BENCHTIME) $(PKG) # The AGENT-SCENARIO benchmark (cmd/seambench): stand up the condition arms, # run every scenario under each of them, grade the captured runs, and print the # with-vs-without uplift plus a baseline-vs-candidate version delta. # # THIS IS NOT `make bench`. That target above is the Go hot-path # micro-benchmarks -- ns/op, offline, free, part of nobody's bill. This one # drives a real `claude` through real sessions. # # MANUAL ONLY. It is deliberately absent from `check`, `check-fast`, # .githooks/pre-commit, CI, the release flow, and every schedule, and it must # stay that way until the owner decides otherwise: each run spends real API # tokens, needs a `claude` binary with working credentials, and is nondeterministic # enough that one run is a data point rather than a gate (plan:seambench, owner # decision 2026-07-28 -- several monitored manual runs first, automation is a # separate decision). # # make seambench the default matrix, 1 run per cell # make seambench N=5 5 runs per scenario x condition cell # make seambench BASELINE=v0.4.5 + the version-delta table against that ref # make seambench CONDITIONS=vanilla,mechanism SCENARIO=cookie-hardening # make seambench REPORT_ARGS=--no-trials keep the results on disk only # make seambench RUN_ARGS='--timeout 25m' REPORT_ARGS=--regrade # # Everything runs under a throwaway base dir with its own homes, demo repos, # data dirs, keys, and non-live ports; the live ~/.seamless, ~/.claude, and # ~/.codex are never touched. The one live write is the results trial the report # records in the `seambench` lab (REPORT_ARGS=--no-trials skips it). # # OUT is passed to both halves so `report` reads the tree `run` just wrote. If # you point RUN_ARGS at another --base, set OUT too or the two will disagree. N ?= 1 SCENARIO ?= CONDITIONS ?= BASELINE ?= OUT ?= RUN_ARGS ?= REPORT_ARGS ?= SEAMBENCH_OUT = $(if $(strip $(OUT)),--out $(OUT),) seambench: @command -v claude >/dev/null 2>&1 \ || { echo "ERROR: seambench needs a 'claude' binary on PATH with working credentials."; \ echo " (looking for the free ns/op micro-benchmarks? that is 'make bench')"; exit 1; } @echo "==> seambench: the AGENT-SCENARIO benchmark. This runs a real agent and SPENDS REAL API TOKENS." @echo " Cost scales with scenarios x conditions x N (currently N=$(N)). Ctrl-C now if you meant 'make bench'." $(GO) run ./cmd/seambench run -n $(N) $(SEAMBENCH_OUT) \ $(if $(strip $(SCENARIO)),--scenario $(SCENARIO),) \ $(if $(strip $(CONDITIONS)),--conditions $(CONDITIONS),) \ $(if $(strip $(BASELINE)),--baseline $(BASELINE),) \ $(RUN_ARGS) $(GO) run ./cmd/seambench report $(SEAMBENCH_OUT) $(REPORT_ARGS) lint: @command -v golangci-lint >/dev/null \ || { echo "ERROR: golangci-lint not found (go install $(GOLANGCI_INSTALL))"; exit 1; } @golangci-lint version 2>&1 | grep -q 'version v\{0,1\}2\.' \ || { echo "ERROR: .golangci.yml is schema v2 and needs golangci-lint v2; found:"; \ golangci-lint version 2>&1 | head -1 | sed 's/^/ /'; \ echo " go install $(GOLANGCI_INSTALL) (note the /v2 in the path)"; exit 1; } golangci-lint run vet: $(GO) vet $(PKG) # Cross-OS vet. CI runs on ubuntu only, so a darwin- or windows-tagged file # (internal/agentproc/proc_{darwin,windows}.go, the service and update code # that branches per OS) would otherwise compile for the first time inside the # release workflow, after the tag is already pushed. vet type-checks test # files too, which a plain cross-build would not. CGO off matches the release # builds (.goreleaser.yaml) and keeps a missing cross C toolchain out of it. CROSS_TARGETS := darwin/arm64 windows/amd64 cross-check: @for t in $(CROSS_TARGETS); do \ os=$${t%/*}; arch=$${t#*/}; \ echo "cross-check: go vet for $$os/$$arch"; \ GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GO) vet $(PKG) || exit 1; \ done # Known-vulnerability gate. Reachability-based (govulncheck reports only vulns # whose vulnerable symbols this code actually calls), so it stays quiet about # the long tail in transitive deps and fails only on something real. # # A hard requirement, like golangci-lint -- an advisory "skip if missing" gate # is the kind that is green on your laptop and red nowhere, which is how the # 16 hits in the 2026-07-19 audit accumulated unseen in the first place. It is # in `check` but NOT `check-fast`: it queries vuln.go.dev, and the pre-commit # hook has to work on a plane. # # scripts/vulncheck.sh holds the exceptions: an advisory the code is shown not # to be exposed to, though govulncheck still sees the calls, allowed until a # re-check date (today GO-2026-6911, which has no fixed mcp-go: every MCP # client refuses redirects instead). It needs jq. vulncheck: @scripts/vulncheck.sh $(PKG) fmt: @$(GOFILES) | xargs gofmt -w # Report drift instead of fixing it, so `make check` can fail on it. fmt-check: @drift=$$($(GOFILES) | xargs gofmt -l); \ test -z "$$drift" || { echo "gofmt drift (run 'make fmt'):"; echo "$$drift"; exit 1; } # The documentation site: markdown in docs-src/ -> static HTML in docs/docs/, # committed and served by the same GitHub Pages config as the landing page. # Two pages are generated from the code (the MCP tool reference reads # mcp.Catalog(), the config reference reflects config.Defaults()), so a tool or # key that changes makes the committed output stale -- which docs-check catches. docs: $(GO) run ./cmd/docsgen -src $(DOCS_SRC) -out $(DOCS_OUT) -site $(SITE_ROOT) # Staleness gate. Regenerates into a temp dir and diffs, rather than rewriting # the working tree and running `git diff`: this never mutates what you are # editing, and -r catches untracked files that git diff cannot see (a page # deleted from docs-src/ still committed under docs/docs/). The site-root files # are diffed by name -- the rest of $(SITE_ROOT) is hand-written and not ours to # compare -- so a stale committed sitemap fails the gate like any stale page. docs-check: @scripts/changelog.sh --check @tmp=$$(mktemp -d) || exit 1; \ trap 'rm -rf "$$tmp"' EXIT; \ $(GO) run ./cmd/docsgen -src $(DOCS_SRC) -out "$$tmp/docs" -site "$$tmp" >/dev/null || exit 1; \ ( diff -r "$$tmp/docs" $(DOCS_OUT) && \ diff -r "$$tmp/scenarios" $(SITE_ROOT)/scenarios && \ for f in $(SITE_FILES); do diff "$$tmp/$$f" $(SITE_ROOT)/$$f || exit 1; done ) > "$$tmp/diff" 2>&1 \ || { echo "docs drift: $(DOCS_OUT), $(SITE_ROOT)/scenarios, or the site-root crawler files do not match $(DOCS_SRC) (run 'make docs' and commit)"; \ head -40 "$$tmp/diff"; exit 1; } docs-serve: docs $(GO) run ./cmd/docsgen -src $(DOCS_SRC) -out $(DOCS_OUT) -serve $(DOCS_ADDR) # The /docs/changelog/ page, regenerated from the git tags. Deliberately a # separate, release-time step rather than part of `make docs`: release dates # are real wall-clock data, and the docsgen render must stay byte-deterministic # (TestRenderIsDeterministic, docs-check) -- a date injected there would drift # the committed site on every unrelated PR. Run this after tagging a release, # then commit the rewritten docs-src/changelog.md with the re-rendered site; # between releases `docs-check` verifies the committed source against the tags. changelog: @scripts/changelog.sh @$(MAKE) docs # The full gate: everything that must be green before declaring work done # (AGENTS.md > "Verification before declaring done"). Sequential $(MAKE) calls # rather than prerequisites so it fails at the first red step and stays ordered # under `make -j`. Cheapest and most-likely-to-fail steps come first; test-race # is last because it is the slowest by far. check: @$(MAKE) build @$(MAKE) vet @$(MAKE) cross-check @$(MAKE) fmt-check @$(MAKE) docs-check @$(MAKE) installer-check @$(MAKE) site-check @$(MAKE) lint @$(MAKE) vulncheck @$(MAKE) test-race @echo "check: all green" # The fast half of `check`: everything except test-race, which is ~37s against # ~3s for these six combined. This is what .githooks/pre-commit runs, and it is # the quick iterate loop by hand. `build` is omitted as redundant -- vet # type-checks, so it already fails on anything that would not compile. # Cheapest-first, same as `check`. NOT a substitute for the full gate. check-fast: @$(MAKE) fmt-check @$(MAKE) vet @$(MAKE) docs-check @$(MAKE) installer-check @$(MAKE) site-check @$(MAKE) lint @echo "check-fast: all green (test-race not run -- 'make check' is the full gate)" # $(INSTALLER) is served verbatim at https://thereisnospoon.org/install and is # the first command a new user ever runs, so a syntax error in it is a broken # front door that no Go test can see. `sh -n` is the gate because it needs # nothing installed. shellcheck is the better tool and runs when it happens to # be present, but only advisory: a gate that fails based on what you have # installed is a gate that is green in CI and red on your laptop. installer-check: @sh -n $(INSTALLER) || { echo "ERROR: $(INSTALLER) has a syntax error"; exit 1; } @if command -v shellcheck >/dev/null; then shellcheck -s sh $(INSTALLER) || true; fi @echo "installer-check: $(INSTALLER) parses" @# The Windows installer is served the same way, so a syntax error in it is the @# same broken front door. pwsh is the parser (the .ps1 analog of `sh -n`); @# unlike shellcheck this fails on a real parse error, but only when pwsh is @# present (CI's ubuntu runner has it), so the gate is never tool-dependent. @if command -v pwsh >/dev/null 2>&1; then \ pwsh -NoProfile -Command '$$err=$$null; [void][System.Management.Automation.Language.Parser]::ParseFile("$(PS_INSTALLER)", [ref]$$null, [ref]$$err); if ($$err.Count) { $$err | ForEach-Object { [Console]::Error.WriteLine($$_.Message) }; exit 1 }' \ || { echo "ERROR: $(PS_INSTALLER) has a syntax error"; exit 1; }; \ echo "installer-check: $(PS_INSTALLER) parses"; \ else \ echo "installer-check: pwsh not found; $(PS_INSTALLER) parse skipped (advisory)"; \ fi # docs/index.html is the one page with no generator behind it, so it is the one # page that can lie indefinitely: the curl|sh installer shipped in d1e926b with # every generated surface correct and this gate green, while the hero pill still # said `go install`. The script checks only what is mechanically verifiable -- # the install command each surface teaches, the subcommands the page names, and # whether the copy buttons copy what they show. The prose is still on you. # Paths and the canonical install command live in the script, not here, so it # stays runnable on its own and there is one place to change them. site-check: @scripts/site-check.sh # Restamp static/site.css and static/site.js with a content-hash ?v= in the # hand-written landing page, so a changed asset gets a fresh URL that the CDN # edge cache has never seen instead of serving stale behind its TTL. Run after # editing either asset; site-check fails until the stamped hash matches. site-stamp: @scripts/site-stamp.sh # Submit the committed sitemap's URL list to the IndexNow-federated engines # (Bing, Naver, Seznam, Yandex; Google does not participate). Manual by design: # run once after a deploy has reached the live site -- the script verifies the # served key file first, the same check the engines make. Never wire this into # CI or a hook: a ping per commit is the over-submission pattern the engines # deprioritize, and a failing HTTP call must not redden a build. DRY=1 prints # the payload without touching the network. DRY ?= indexnow: @scripts/indexnow.sh $(if $(strip $(DRY)),--dry-run,) # The search-visibility north star: stars plus per-release asset download # counts. The installer pulls binaries from GitHub Releases, so the download # counter tracks installs rather than a proxy -- inflated by CI runs, mirrors, # and scanners, so read the trend, not the absolute number. metrics: @scripts/metrics.sh # Git hooks. Committed under .githooks/ because .git/hooks is neither committed # nor shared between worktrees. core.hooksPath is repo-local config, so enabling # them is a per-clone opt-in rather than something a checkout imposes on you. # The path stays relative: git resolves it against each worktree's top level, so # linked worktrees under .claude/worktrees/ run their own checkout's copy. # (Distinct from `install-hooks`, which installs agent-client hooks.) install-git-hooks: @git config core.hooksPath .githooks @echo "git hooks enabled: pre-commit runs 'make check-fast' (bypass: git commit --no-verify)" uninstall-git-hooks: @git config --unset core.hooksPath 2>/dev/null || true @echo "git hooks disabled" # Local dry-run of the release pipeline (.goreleaser.yaml): validates the # config and cross-compiles every platform into dist/ without tagging or # publishing. Real releases run in CI on a v* tag (.github/workflows/release.yml). # --skip=sign: the `signs:` stage is keyless cosign, which needs the GitHub OIDC # token that only exists inside the release workflow. Off a runner it falls back # to an interactive browser flow, so a local dry-run would hang instead of # validating the build. Signing is exercised for real on a v* tag. # Needs goreleaser v2.19.0+: older versions refuse the cask's # hooks.post.install_steps ("field install_steps not found"). release-snapshot: @command -v goreleaser >/dev/null \ || { echo "ERROR: goreleaser not found (brew install goreleaser, v2.19.0+)"; exit 1; } goreleaser release --snapshot --clean --skip=sign # The macOS Touch ID approver (macos/SeamApprove, Swift; macOS only, opt-in): # bin/approver/Seamless Approve.app. Nothing else builds, installs or launches # it -- `seam touchid enroll` installs a release's signed copy. With no # environment it builds an ad-hoc signed bundle for compile checks, which has # no key store. APPROVER_SIGN_IDENTITY + APPROVER_TEAM_ID + APPROVER_PROFILE # (all three) sign it with the hardened runtime and embed the profile; see # scripts/approver/build.sh. Notarizing is scripts/approver/notarize.sh. # approver-test runs the helper's unit tests (no Touch ID prompt, no keychain). .PHONY: approver approver-test approver: scripts/approver/build.sh approver-test: swift test --package-path macos/SeamApprove --scratch-path $(BIN_DIR)/approver/.build tidy: $(GO) mod tidy run: build $(BIN_DIR)/$(BINARY) serve doctor: build $(BIN_DIR)/$(BINARY) doctor # Open the console pre-authenticated: build, then have the daemon render a # one-shot self-submitting login page (POSTs the static key, sets the cookie) # and open it in the default browser. Requires the server to be running. console: build $(BIN_DIR)/$(BINARY) console-open # Same, but force Google Chrome regardless of the default browser -- agents # drive Chrome (Claude-in-Chrome), so this hands the auth cookie to the browser # they can actually see. console-chrome: build $(BIN_DIR)/$(BINARY) console-open --browser "Google Chrome" # Boot the (freshly rendered) $(SVC_PLIST): evict any old instance, bootstrap # with retry, and verify. bootout is asynchronous: it returns before the old # daemon has exited, the label lingers until it has, and bootstrapping too soon # fails with "Bootstrap failed: 5: Input/output error". The retry runs every # 0.1s, so a daemon that takes a few dozen ms to drain costs one retry rather # than a whole second per install; the 100 tries (10s and up) outlast launchd's # 5s exit timeout, after which a daemon that ignored SIGTERM is SIGKILLed and # its label freed. # # The plist sets RunAtLoad, so a bootstrap that succeeds has already started the # daemon -- kickstart is the fallback for the path where it did not (a wedged job # that bootout could not evict, leaving every bootstrap to fail as already # loaded). Kicking unconditionally would kill a process launchd spawned # milliseconds earlier, and the replacement is then held for the full 10s # ThrottleInterval: a correct-but-10s-late restart, which is what _wait-healthy # was tripping over. # # `install` always routes through here, including when the plist is unchanged. # launchd caches the code-signing identity of a loaded job's executable, so # re-execing in place (kickstart -k) after the binary underneath it was replaced # runs against a stale identity: macOS 26 AMFI launch constraints are enforcing, # and the spawn is killed after ~14ms with OS_REASON_CODESIGNING. KeepAlive then # retries, but only once the 10s throttle interval elapses -- so the daemon # returns ~10s late (blowing _wait-healthy) and every install leaves a crash # report. bootout/bootstrap re-registers the job against the new binary, so the # first spawn survives and the restart costs about a second and a half, nearly # all of it the new binary's own startup (1.42s from the shutdown signal to # listening, measured on a make install on 2026-10-09). Go binaries are adhoc # linker-signed with no CMS blob, which is what the constraint rejects; nothing # here can be signed away. # # First, retire $(LEGACY_SVC_LABEL), the label before the rename: its job runs # the same seamlessd serve on the same data dir and port, so it is booted out # and waited out (launchd holds the label until the daemon has exited), and its # plist deleted so the next login does not load it again. docs/install's # retire_launchd_label does the same for curl installs. _reload-service: @if [ -f $(LEGACY_SVC_PLIST) ] || launchctl print gui/$(UID)/$(LEGACY_SVC_LABEL) >/dev/null 2>&1; then \ echo "retiring $(LEGACY_SVC_LABEL) (renamed $(SVC_LABEL))"; \ launchctl bootout gui/$(UID)/$(LEGACY_SVC_LABEL) 2>/dev/null || true; \ for i in $$(seq 1 100); do \ launchctl print gui/$(UID)/$(LEGACY_SVC_LABEL) >/dev/null 2>&1 || break; \ sleep 0.1; \ done; \ rm -f $(LEGACY_SVC_PLIST); \ fi @launchctl bootout gui/$(UID)/$(SVC_LABEL) 2>/dev/null || true @booted=0; \ for i in $$(seq 1 100); do \ if launchctl bootstrap gui/$(UID) $(SVC_PLIST) 2>/dev/null; then booted=1; break; fi; \ sleep 0.1; \ done; \ [ $$booted -eq 1 ] || launchctl kickstart -k gui/$(UID)/$(SVC_LABEL) 2>/dev/null || true @launchctl print gui/$(UID)/$(SVC_LABEL) >/dev/null 2>&1 \ || { echo "ERROR: $(SVC_LABEL) failed to bootstrap; check $(SVC_LOG)"; exit 1; } # The one deploy path: snapshot the binaries + config to stable locations # independent of this working tree, then point launchd AND the selected clients' # hooks/MCP registrations at the copies. Because nothing live resolves through # ./bin, `make build`, a # branch switch, or a moved/cleaned repo cannot change what the running daemon # and every agent's hooks execute -- swapping them is this target, deliberately. # # It is also the iterate loop: the marginal cost over `make build` is two file # copies and a service reload (see _reload-service for why that reload is # unconditional rather than a kickstart in place). # # One instance per machine (data dir ~/.seamless; bind from the config's addr). # Override the location with `make install PREFIX=/opt`. # # Both halves that assume a local daemon -- the service and the health poll -- # go through a role branch (_install-service, _check-health), because a `role: # client` config means there is no daemon on this machine to render, supervise # or poll. Everything else here is role-blind: a client gets the same binaries, # the same config location and the same wired hooks. # # Each of those runs as a sub-make so its $(shell ...) reads the config AFTER # _seed-config may have written it, the same reason _wait-healthy has always # been invoked that way. # # _check-takeover runs first, before the build and before anything is written. install: _check-takeover build @mkdir -p $(PREFIX_BIN) $(CONFIG_DIR) @install -m 0755 $(BIN_DIR)/$(BINARY) $(PREFIX_BIN)/$(BINARY) @install -m 0755 $(BIN_DIR)/$(CLI) $(PREFIX_BIN)/$(CLI) @$(MAKE) _seed-config @$(MAKE) _install-service @SEAMLESS_CONFIG=$(CONFIG) $(PREFIX_BIN)/$(BINARY) install-hooks --client $(CLIENT) --seam $(PREFIX_BIN)/$(CLI) @$(MAKE) _check-health @$(PREFIX_BIN)/$(BINARY) install-summary --bin-dir $(PREFIX_BIN) --config $(CONFIG) --bins $(BINARY),$(CLI) # Refuse to replace an install the curl installer made. On such a machine # `make install` would quietly end automatic updates: the binaries it copies are # source builds (the first gate of update.Detect), and the plist it renders # lacks the installer's marker. So when a service definition the installer owns # carries that marker -- the launchd plist under either label, or the systemd # user unit (`make install` is macOS-only, but on Linux it would overwrite the # binaries before failing at launchctl) -- this stops before anything is # written. FORCE=1 takes the install over, and the installer hands it back. # # Plain grep on what is on disk, never the binary about to be installed: a new # build must not be the one that decides whether it may replace the old one. # INSTALLER_MARKER must equal update.InstallerMarker # (TestMakeInstallGuardUsesTheInstallerMarker). A Homebrew or client install # writes no service definition, so this passes it by. Under `make -n` this # recipe is printed, not run. INSTALLER_MARKER := Written by https://thereisnospoon.org/install SYSTEMD_USER_UNIT := $(HOME)/.config/systemd/user/seamless.service FORCE ?= _check-takeover: @for def in $(SVC_PLIST) $(LEGACY_SVC_PLIST) $(SYSTEMD_USER_UNIT); do \ [ -f "$$def" ] && grep -qF '$(INSTALLER_MARKER)' "$$def" || continue; \ if [ -n "$(strip $(FORCE))" ]; then \ echo "taking over the installer's install ($$def): automatic updates stay off until the installer runs again"; \ exit 0; \ fi; \ echo "ERROR: the curl installer manages Seamless on this machine ($$def)."; \ echo " make install would replace its release with a source build, and automatic updates would stop."; \ echo " To try a build, run make build and start ./bin/seamlessd with a scratch config (SEAMLESS_CONFIG)."; \ echo " To take the install over anyway: make install FORCE=1 (running the installer again hands it back)."; \ exit 1; \ done # Render the plist and (re)boot the service -- unless this is a client install. # # Under `role: client` there is nothing to supervise: serve() refuses before it # opens a file or a port, so bootstrapping it anyway hands launchd a job that # exits immediately, KeepAlive restarts it on the 10s throttle, and the install # then fails at the health poll -- a crash loop and a red install on a machine # that is in fact correctly configured. The curl installer prints this step as # skipped and names the server; so does this. # # The launchd-only directories are created here rather than in `install` for the # same reason: ~/Library/LaunchAgents holds a plist a client never renders, and # ~/.seamless is the data dir a client deliberately does not have (it holds no # corpus and opens no database), so neither should appear on one. # # The reload is a separate recipe line, and deliberately not folded into the # shell block above it: make RUNS any line containing $(MAKE) even under -n, so # a combined line would have `make -n install` render and install the plist for # real. Split, the dry run prints the render and only recurses for the reload, # which is what this file did before the role branch existed. _install-service: @if [ -n "$(IS_CLIENT)" ]; then \ echo "service skipped (client of $(CONFIG_SERVER_URL)) -- no daemon on this machine"; \ exit 0; \ fi; \ mkdir -p $(HOME)/Library/LaunchAgents $(HOME)/.seamless; \ tmp=$$(mktemp) || exit 1; \ sed -e 's#__BINARY__#$(PREFIX_BIN)/$(BINARY)#g' \ -e 's#__CONFIG__#$(CONFIG)#g' \ -e 's#__LOG__#$(SVC_LOG)#g' \ $(SVC_TEMPLATE) > $$tmp || exit 1; \ if cmp -s $$tmp $(SVC_PLIST); then \ rm -f $$tmp; \ echo "reloading $(SVC_LABEL) (plist unchanged)"; \ else \ install -m 0644 $$tmp $(SVC_PLIST) || exit 1; \ rm -f $$tmp; \ echo "reloading $(SVC_LABEL) (plist updated)"; \ fi @if [ -z "$(IS_CLIENT)" ]; then $(MAKE) _reload-service; fi # Which end to ask "is it serving?" -- this machine's listener, or the server a # client install dials. _check-health: @if [ -n "$(IS_CLIENT)" ]; then $(MAKE) _check-server; else $(MAKE) _wait-healthy; fi # launchd returns as soon as it has *started* the process, but the daemon binds # its listener ~100ms later. Without this, `make install && make doctor` -- the # documented upgrade sequence -- races a server that is not accepting yet and # reports a failure that fixes itself. Poll until it actually answers, so a green # install means it is serving. # # Plaintext first, then TLS with verification disabled. Once tls.cert_file and # tls.key_file are set the listener refuses http entirely, so an http-only poll # can never succeed and `make install` fails on a TLS install that in fact # worked -- every step before this one has already run. The Makefile deliberately # does not parse the tls keys to pick a scheme: it greps only `addr:` out of the # config, and re-deriving more of it here is the transcription that drifts. # Probing both is the same answer without the second copy. # # Verification is skipped because this asks "is something serving here" and # nothing else: no body is read and no credential is sent, and the certificate # covers the host of `server_url` rather than the bind address being dialed, so # a verifying probe would report a correctly configured daemon as down. This # mirrors serverReachable in cmd/seamlessd/console.go, which fails the same way # for the same reason. _wait-healthy: @for i in $$(seq 1 50); do \ curl -sf --max-time 1 -o /dev/null "http://$(ADDR)/healthz" 2>/dev/null && exit 0; \ curl -sfk --max-time 1 -o /dev/null "https://$(ADDR)/healthz" 2>/dev/null && exit 0; \ sleep 0.1; \ done; \ echo "ERROR: no /healthz from $(ADDR) over http or https after 50 attempts; check $(SVC_LOG)"; exit 1 # The client half of _wait-healthy. Nothing was started here, so there is no # listener race to wait out: this asks the server whether it answers this # machine, and a few tries absorb a slow link rather than a boot. server_url # carries its own scheme, so unlike _wait-healthy there is no scheme to guess. # # A silent server is a WARNING, not a failure, matching the curl installer: the # agent clients are wired either way and the install is complete, and re-running # `make install` is not how you retry a server that was merely asleep. # # -k for the reason reachabilityProbe gives in cmd/seamlessd/doctor.go: no # credential is sent and no body is read, so this asks only "is something # serving there", and a verifying probe would report a server behind a private # CA as down. Whether this machine TRUSTS that certificate is tls.ca_file's # question, asked on the surfaces that carry the bearer key (seam doctor, the # hooks, the MCP bridge), all of which fail loudly when it is wrong. _check-server: @for i in 1 2 3 4 5; do \ curl -sfk --max-time 2 -o /dev/null "$(CONFIG_SERVER_URL)/healthz" 2>/dev/null \ && { echo "healthz ok -- $(CONFIG_SERVER_URL)"; exit 0; }; \ sleep 0.2; \ done; \ echo "WARNING: no /healthz from $(CONFIG_SERVER_URL) -- this machine is wired to it anyway."; \ echo " Check that seamlessd is serving there, that its addr is not loopback-only,"; \ echo " and that this machine can reach that host and port." # Seed $(CONFIG) on first install only -- never clobber a config that may hold an # edited bearer key. ./seamless.yaml (gitignored, the pre-install layout's live # config) wins over the committed template so an existing setup keeps its keys; # a fresh clone gets the example with a generated mcp.api_key, ready to run. _seed-config: @if [ -f $(CONFIG) ]; then \ echo "config kept at $(CONFIG) (delete it to re-seed)"; \ elif [ -f seamless.yaml ]; then \ install -m 0600 seamless.yaml $(CONFIG) && echo "seeded $(CONFIG) from ./seamless.yaml"; \ else \ install -m 0600 seamless.yaml.example $(CONFIG); \ KEY=$$(openssl rand -hex 32); \ sed -i '' -e "/^mcp:/,/api_key:/ s/api_key: \"\"/api_key: \"$$KEY\"/" $(CONFIG); \ echo "seeded $(CONFIG) from seamless.yaml.example with a generated mcp.api_key"; \ fi # Full uninstall, delegated to the Go command so there is ONE cross-OS # implementation (service + hooks + MCP + skills + binaries) instead of the old # macOS-only launchctl+rm, which reversed neither the hooks/MCP/skills nor the # Linux/Windows services. --yes because make is non-interactive; --install-dir # honors `make install PREFIX=...`. $(CONFIG) and ~/.seamless are kept (your # memories and notes are markdown that outlive the program) unless you opt in: # `make uninstall PURGE=1`. `build` first so a clean tree still has a binary to # run -- it tears down the INSTALLED service and $(PREFIX_BIN) binaries, a # different file from ./bin, so it never deletes the one running it. PURGE ?= uninstall: build @$(BIN_DIR)/$(BINARY) uninstall --yes --install-dir $(PREFIX_BIN) $(if $(strip $(PURGE)),--purge,) # Self-update to the latest published release, delegated to the Go binary so the # release-fetch + checksum + swap logic keeps ONE home (the installer scripts the # binary re-runs) -- the same delegation `uninstall` and the lifecycle verbs use. # `build` first so a clean tree has a binary to run. Note this installs the latest # RELEASE into $(PREFIX_BIN), which may be OLDER than this clone's HEAD -- to # deploy your local build instead, use `make install`. CHECK=1 only reports # installed vs latest and changes nothing. CHECK ?= update: build @$(BIN_DIR)/$(BINARY) update $(if $(strip $(CHECK)),--check,) # Service lifecycle, delegated to the Go binary so there is ONE cross-OS # implementation (launchd / systemd --user / Scheduled Task) instead of the old # macOS-only launchctl targets -- the same move `uninstall` made. `build` first # so a clean tree has a binary to run; it drives the INSTALLED service wherever # the installer placed it. A not-installed service prints a hint, not a crash. start: build @$(BIN_DIR)/$(BINARY) start stop: build @$(BIN_DIR)/$(BINARY) stop restart: build @$(BIN_DIR)/$(BINARY) restart status: build @$(BIN_DIR)/$(BINARY) status # The other target that assumes a local daemon: there is no log on a client # because there is no process writing one. Say where it is instead of pointing # at a path on this machine that will never exist. logs: @if [ -n "$(IS_CLIENT)" ]; then \ echo "no local log: this machine is a client of $(CONFIG_SERVER_URL); the daemon logs there"; \ exit 1; \ fi; \ test -f $(SVC_LOG) || { echo "no log yet at $(SVC_LOG)"; exit 1; } @tail -f $(SVC_LOG) install-onboard-skill: @scripts/install-skill.sh seam-onboard $(CLIENT) uninstall-onboard-skill: @scripts/uninstall-skill.sh seam-onboard $(CLIENT) install-research-skill: @scripts/install-skill.sh seam-research $(CLIENT) uninstall-research-skill: @scripts/uninstall-skill.sh seam-research $(CLIENT) clean: rm -rf $(BIN_DIR) dist coverage.*