Ctrl K
ComunidadGuías…

Makefile

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.*