Compare commits

...
9 Commits
Author SHA1 Message Date
mathias ef7ccc8295 fix(lint): check resp.Body.Close()'s error (errcheck)
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 18s
CD / Build & Import (push) Successful in 6s
Caught by CI, not locally -- this sandbox's golangci-lint crashes on any
repo, a pre-existing environment issue unrelated to this code (documented
in dispatch's own session history). gitea-mcp's actual CI run has a
working linter and correctly flagged this.
2026-09-05 11:27:22 +02:00
mathias 4d6597639e feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
CD / Lint / Test / Vet (push) Failing after 4s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped
dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
Gitea's own token-creation and admin-user endpoints reject token auth
outright -- POST /users/{u}/tokens returns 'auth required', POST
/admin/users returns 'token does not have at least one of required
scope(s), required=[write:admin]' -- for ANY existing token, by design. An
existing token cannot mint another token or create an account; the only
way through either endpoint is real basic auth as an actual site admin.
That is exactly what a sandboxed agent session cannot safely hold.

internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable
against a fake transport (5 tests, no network). Given real admin
username/password, it creates the target account (or resets its password
if it already exists -- deliberately unconditional, gated behind a
required ConfirmServiceAccount flag since that reset is only safe for a
dedicated bot account), mints it a scoped token by authenticating AS that
account (Gitea requires this specifically -- an admin cannot mint on
another account's behalf), grants it read-only collaborator access on
named repos, and stores the result via a SecretStore -- never returning
the token or either password to the caller.

internal/localtools: the MCP tool wrapper (mint_service_account_token) and
the real op-CLI-backed SecretStore (token passed via stdin, never argv,
per the estate's own secret-handling rule). Deliberately its OWN package,
not internal/tools -- this capability is far too dangerous to risk it ever
being wired into RegisterAll and reachable from the shared HTTP gitea-mcp
server, even behind OIDC.

internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line,
standard MCP stdio framing) for the existing registry.Tool interface --
the only transport gitea-mcp had before this was HTTP (server.go), which a
sandboxed session can reach but a real admin password never should be
piped through.

cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code
session (.mcp.json stdio server) or run by hand, via 'op run --env-file
~/.op-env' so admin credentials arrive just-in-time and never touch a
sandboxed session.

11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the
shared HTTP server) changed.
2026-09-05 11:25:41 +02:00
mathias 419ae19cdb fix(context): sync to the estate canonical context-sync.sh, drop Cursor/Aider
CD / Lint / Test / Vet (push) Successful in 24s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 5s
This repo's copy of context-sync.sh was two days stale against the version
Mathias's Cursor/Aider decision (2026-09-01) already landed in dma-cli:
still generated .cursorrules and .aider.conventions.md, and lacked the
--force/placeholder pre-flight and .context/mcp.json root-merge dma-cli's
copy has had since.

Found while building the estate-wide context-sync drift gate (ADR-0019) --
this is exactly the drift the gate exists to catch, found by hand while
establishing the baseline it will check going forward.

- script synced to the canonical hash (dma-cli's current copy)
- .cursorrules, .aider.conventions.md, .aider.conf.yml deleted
- Taskfile's drift assertion and file list narrowed to match
- AGENTS.md, .context/system-prompt.txt regenerated against canonical

task check -> exit 0.
2026-09-05 08:01:07 +02:00
mathiasandClaude Opus 5 e4d07c106c ci(deploy): refuse to patch a manifest with an empty IMAGE_TAG (infra#311)
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 9s
On 2026-08-25 a deploy job ran with an empty IMAGE_TAG, wrote
`image: localhost:5000/gitea-mcp:` into the infra repo and pushed it.
That is invalid YAML, so Flux failed the ENTIRE apps Kustomization --
every app stopped reconciling for about an hour, including three
unrelated deploys queued behind it.

This job's existing check could not catch it. It greps for
`image: <repo>:${IMAGE_TAG}` after the sed, and with IMAGE_TAG empty that
pattern matches the corrupted line itself, so the verification passes
because of the bug rather than despite it.

Asserting the input up front is the fix: an empty tag now fails the job
before anything is written, let alone committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 17:25:23 +02:00
mathiasandClaude Opus 5 1d75bf9b25 fix(ci): no expression syntax in a run: block's comments
CD / Lint / Test / Vet (push) Successful in 10s
CD / Deploy via GitOps (push) Successful in 4s
CD / Build & Import (push) Successful in 5s
act interpolates a `run:` block INCLUDING its shell comments, so prose
containing Actions expression syntax is evaluated as an expression. A
comment I added yesterday explaining why a fallback was computed in shell
contained that syntax as an example; act tried to evaluate it, failed
with "Unavailable context: a", and the step then wrote NO outputs at all.

In gitea-mcp that emptied `needs.build.outputs.image-tag`, so its deploy
job committed `image: localhost:5000/gitea-mcp:` to the infra repo --
which is not valid YAML, so Flux's apps Kustomization went Ready:False
and NOTHING in k3s/apps reconciled estate-wide until infra 51ceb7c.

The build still went green throughout, because the shared build-image.sh
falls back to `git rev-parse --short HEAD` when SHA_TAG is empty. A
robust default hid a broken input -- worth remembering as a pattern.

Same comment text removed here; the explanation stays, the syntax does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 15:29:40 +02:00
mathiasandClaude Opus 5 417ab60707 ci: call the shared build-image script instead of an inline copy (infra#309)
CD / Lint / Test / Vet (push) Successful in 9s
CD / Build & Import (push) Successful in 6s
CD / Deploy via GitOps (push) Failing after 1m2s
Wave 2. The buildctl/skopeo step here was one of nineteen near-identical
copies; the implementation now lives in .gitea/build-image.sh,
byte-identical to mathias/infra's ci-templates/build-image.sh and held
there by a drift gate. Labels, tags and build-args are inputs.

Behaviour unchanged: same registry, same :sha/:latest/version tags, same
revision and source labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 12:35:52 +02:00
mathiasandClaude Opus 5 f6446c2515 ci: drop the k3s ctr smoke test, impossible on the containerized runner
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 7s
CD / Deploy via GitOps (push) Successful in 6s
The step ran `sudo k3s ctr images pull` / `ctr run` against the host's
containerd. That worked when the runner was the Arch host; since it was
containerized (infra#233) the image has no sudo, no ctr and no containerd
socket, so the step fails with `sudo: command not found`. It only
surfaced now because the buildah wall (infra#241) killed the job before
this step ever ran.

The deploy job's `Verify rollout` and `Confirm pod running new image`
assert the same property against the real workload rather than a 5s
throwaway container, which is why tapir dropped its smoke test under the
same issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 06:54:55 +02:00
mathiasandClaude Opus 5 50c447f673 ci(cd): build with buildctl instead of rootless buildah (infra#241)
CD / Build & Import (push) Failing after 14s
CD / Deploy via GitOps (push) Skipped
CD / Lint / Test / Vet (push) Successful in 27s
Rootless buildah in the containerized gitea-runner pod cannot remap
UID/GID on a fresh base-image pull and has no OCI runtime, so this
Dockerfile's `RUN apk add` / `RUN go build` cannot execute there at all.
The last green run here (2026-07-22) predates the runner's
containerization (infra#233, 2026-07-29), so the build has been latently
broken since.

buildctl hands the build to koala's host buildkitd over the socket the
pod already mounts; skopeo pushes the resulting OCI archive once per tag,
since buildctl has no `buildah push` equivalent. The VERSION build-arg
carries over as `--opt build-arg:VERSION=`, so the ldflags version stamp
is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 06:49:47 +02:00
mathiasandClaude Sonnet 5 43714047be fix(auth): owner allowlist trusts pass-through-authenticated callers (#59)
CD / Lint / Test / Vet (push) Successful in 9s
CD / Build & Import (push) Successful in 25s
CD / Deploy via GitOps (push) Has been skipped
Allowlist.Check now takes ctx: when the caller authenticated with
their own Gitea PAT (pass-through, v0.12.0), it skips the static
GITEA_MCP_ALLOWED_OWNERS check entirely — Gitea's own permission
model already gates that caller's access more precisely than a coarse
owner-name list can. The static list still applies unchanged for the
shared static-token/JWT path, where it's the only defense against the
service token's blast radius.

Mechanical: every tool call site already had ctx in scope, so this is
a signature-only change at 41 call sites, no other tool behavior
changes. Closes the "Deferred" item from #59.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:31:43 +02:00
62 changed files with 1660 additions and 930 deletions
-2
View File
@@ -1,2 +0,0 @@
read: .aider.conventions.md
auto-commits: false
-386
View File
@@ -1,386 +0,0 @@
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:**
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+90 -21
View File
@@ -53,14 +53,30 @@ These rules apply to every task across every project, regardless of harness.
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
the tiered merge model in `assessor-loop`
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
what it sells instead of merging everything on green and claiming otherwise.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
collisions between parallel sessions: two were not branching problems at all — a
generated artifact written to the same path by both (branching turns that into a
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
push resolved by rebase in seconds. Use a worktree per session on shared repos and
timestamp generated artifacts. Do not reach for a branch.
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
issues, review comments live on a thread separate from the issue's, and a
merged-and-deleted branch cannot be amended (brain:
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
**Exception — external contributor or client four-eyes requirement:** Use PR flow
when a human reviewer outside the project is required. Document the reason in
PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
@@ -78,6 +94,26 @@ These rules apply to every task across every project, regardless of harness.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
**Chat is not a durable store. Never rely on Mathias remembering anything.**
Every gap, caveat, risk, open question and next step must land in one of four
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
lost the moment the session closes — and this estate has repeatedly proved that a
remembered convention is an unreliable control.
This applies hardest to what is tempting to leave as a closing remark: "worth
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
## Default stack
| Layer | Default | Fallback | Last resort |
@@ -88,7 +124,7 @@ These rules apply to every task across every project, regardless of harness.
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
@@ -97,7 +133,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
@@ -135,10 +176,10 @@ Three machines on Tailscale:
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
- **Networking**: Tailscale mesh
## Project landscape
@@ -234,10 +275,11 @@ When working on a project tagged with a client name:
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
re-add adapters for them:
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
- Any other tool that reads `AGENTS.md`, which is the de facto convention
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
@@ -249,15 +291,29 @@ Canonical sources of truth:
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
you touch the repo, along with their entries in that repo's `task check`
assertion.
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
2. On any other host, `git pull` brings both. Which file a harness reads does
NOT split cleanly by lookup strategy, so do not reason from the strategy:
- `CLAUDE.md` — Claude Code (walks up the tree)
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
up** from cwd and takes the first `AGENTS.md` it finds
(user-level `~/.vibe/AGENTS.md`)
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
any harness, it is for pasting
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
inside that repo, so this works — but by construction, not by luck.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
@@ -268,7 +324,20 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
**Skill trigger table — load before starting, not after getting stuck:**
-389
View File
@@ -1,389 +0,0 @@
# Cursor rules — auto-generated
# Do not edit. Run: task context:sync
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
actual last tag — stated versions in docs drift stale.
- **Push** main and the tag (CI is the gate).
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
the reusable patterns and the footguns that would bite anyone again, never
project status. See *Knowledge base — when to write* below.
- **File discovered-but-deferred work as tracker issues** on the project's own
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
"out of scope, recorded" rot in a commit message; make it a ticket with a
source pointer.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Secret handling (every harness, every command)
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
claudewatcher → brain/wiki → gitea history. A secret printed once is
searchable forever, and clearing it means rotating the key. So:
1. **Never print, echo, log, or transform a secret to inspect it.** No
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
to defeat `op run`'s output masking (it masks raw values; base64 hides them
from the mask — that exact trick leaked a key on 2026-06-11).
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
*inside* `op run --env-file ~/.op-env -- <cmd>`. Never place a literal secret
in a command's argv (it lands in the tool call and the transcript).
3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` —
never `${X:-...}` (returns the value when set) and never echo a substring of it.
4. **Cross-host secrets:** run the secret-consuming command on the host that has
the secret; do not forward a raw key over ssh argv/stdout.
5. If a secret does leak into output, say so immediately and flag it for rotation —
don't bury it.
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
**Skill trigger table — load before starting, not after getting stuck:**
| Task type | Load |
|-----------|------|
| Any feature or bug fix | `tdd` |
| Refactor or design | `clean-code` or `solid` |
| Debug | `problem-analysis` |
| Review code or PRs | `code-review` |
| Frame a problem before coding | `problem-analysis` |
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: gitea-mcp
- **Owner**: Mathias
- **Client**: personal
- **Repo**: https://git.d-ma.be/mathias/gitea-mcp
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- **Trunk-Based Development:** commit directly to main. One logical change per commit.
- Run `task check` locally before every push. CI is the quality gate, not branch protection.
- No feature branches, no PRs for solo/agent work.
- Exception: if a parallel agent session is active on this repo, use a short-lived
`agent/<description>` branch and merge within the same session.
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
This project can query the shared knowledge base via MCP or HTTP:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
## Behavior rules
These rules apply to every task in this project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. Commit directly to main. Run `task check` before every push. Never create
feature branches unless a parallel agent is simultaneously active on this repo.
7. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
## Current state — v0.2.5 (2026-05-17)
All v0.2 work is complete and deployed. No active sprint.
### What shipped
| Tag | PR | Tools / fixes |
|-----|----|---------------|
| v0.2.2 | #21 | repo_create, repo_update, repo_mirror_push |
| v0.2.3 | #22 | repo_tree, repo_topics_update, file_read dir fix |
| v0.2.4 | #23 | issue_get, release_create, repo_delete |
| v0.2.5 | #26 | repo_update archived+template, create_project_from_template template_name, pr_files_diff loop fix |
Current main: `e31fd3f`. CI green. Deployed via Flux.
### Next up
1. **`hyperguild new-project` v1** — primary next target.
See brain node `adr-new-project-gitea-first-github-mirror` for full flow spec.
2. **Issue #19** — end-to-end mirror flow verification.
`repo_mirror_push` is implemented but the full flow (create repo → add push mirror → verify sync to GitHub) has not been tested manually. Do this before relying on it in production.
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Build an OCI image with buildctl and push it to the local registry.
#
# CANONICAL COPY: mathias/infra ci-templates/build-image.sh (infra#309).
# Every repo carries a byte-identical copy at .gitea/build-image.sh, written by
# `task ci:sync` and held in place by the drift gate. DO NOT EDIT THE COPIES --
# edit here, re-sync, and the gate will tell you which repos still disagree.
#
# Why this exists: this step was hand-copied into a dozen repos, so migrating it
# off rootless buildah (infra#241) meant eleven near-identical commits, eleven
# verifications, and three separate rediscoveries of the same fix in the two
# repo templates and the gitea-ci skill. The differences between the copies were
# incidental -- a label here, a build-arg there -- never intentional. Those are
# inputs now, not forks of the implementation.
#
# Why buildctl and not buildah: the gitea-runner pod runs with
# `capabilities: drop [ALL]` and `allowPrivilegeEscalation: false`, which
# unconditionally blocks the setuid-root exec that rootless buildah's uid/gid
# remapping needs, and the pod has no OCI runtime at all. buildctl is a thin
# client -- it hands the build to the host's buildkitd over a socket, so the
# privileged layer work happens on the host and this pod's securityContext is
# not in the picture. buildah is absent from the runner image as of baked-v14
# and its absence is asserted at pod start (infra#308).
#
# Inputs, all via environment:
#
# IMAGE (required) image name, e.g. cobalt-dingo
# REGISTRY default localhost:5000
# SHA_TAG default `git rev-parse --short HEAD`
# VERSION_TAG optional extra tag, e.g. v1.2.3 on a tag build
# PUSH_LATEST default 1; set 0 to skip the :latest tag
# CONTEXT default . -- build context directory
# DOCKERFILE_DIR default $CONTEXT -- directory holding the Dockerfile
# DOCKERFILE_NAME optional, when the file is not named `Dockerfile`
# TARGET optional, multi-stage build target (--opt target=)
# BUILD_ARGS optional, space-separated K=V pairs
# BUILD_SECRETS optional, space-separated buildkit secret specs, each
# `id=NAME,src=PATH`. Pass the SPEC only -- the caller
# writes the secret to PATH itself. A secret VALUE must
# never be passed here: it would land in this script's
# argv and therefore in the CI log.
# EXTRA_LABELS optional, space-separated K=V pairs
# GIT_SHA optional, stamped as org.opencontainers.image.revision
# IMAGE_SOURCE optional, stamped as org.opencontainers.image.source
#
# Note on shell style below: every conditional is a full `if` block, never
# `[ -n "$X" ] && cmd`. Under `set -e` the && form exits the script whenever the
# test is false, which is a real bug this estate has already shipped once.
set -euo pipefail
if [ -z "${IMAGE:-}" ]; then
echo "build-image: IMAGE is required" >&2
exit 2
fi
REGISTRY="${REGISTRY:-localhost:5000}"
BUILDKIT_HOST="${BUILDKIT_HOST:-unix:///run/buildkit/buildkitd.sock}"
CONTEXT="${CONTEXT:-.}"
DOCKERFILE_DIR="${DOCKERFILE_DIR:-$CONTEXT}"
PUSH_LATEST="${PUSH_LATEST:-1}"
VERSION_TAG="${VERSION_TAG:-}"
if [ -z "${SHA_TAG:-}" ]; then
SHA_TAG="$(git rev-parse --short HEAD)"
fi
REF="${REGISTRY}/${IMAGE}:${SHA_TAG}"
TAR_FILE="$(mktemp)"
trap 'rm -f "$TAR_FILE"' EXIT
args=(
--frontend dockerfile.v0
--local "context=${CONTEXT}"
--local "dockerfile=${DOCKERFILE_DIR}"
)
if [ -n "${DOCKERFILE_NAME:-}" ]; then
args+=(--opt "filename=${DOCKERFILE_NAME}")
fi
if [ -n "${TARGET:-}" ]; then
args+=(--opt "target=${TARGET}")
fi
for spec in ${BUILD_SECRETS:-}; do
# Opaque id=NAME,src=PATH -- buildkit reads the file, nothing is echoed.
args+=(--secret "${spec}")
done
if [ -n "${GIT_SHA:-}" ]; then
args+=(--opt "label:org.opencontainers.image.revision=${GIT_SHA}")
fi
if [ -n "${IMAGE_SOURCE:-}" ]; then
args+=(--opt "label:org.opencontainers.image.source=${IMAGE_SOURCE}")
fi
for kv in ${BUILD_ARGS:-}; do
args+=(--opt "build-arg:${kv}")
done
for kv in ${EXTRA_LABELS:-}; do
args+=(--opt "label:${kv}")
done
echo "build-image: building ${REF}"
buildctl --addr "$BUILDKIT_HOST" build "${args[@]}" --output "type=oci,dest=${TAR_FILE}"
# buildctl has no registry-push equivalent to `buildah push`, so skopeo copies
# the OCI archive up -- once per tag, from the same archive, so every tag points
# at bit-identical content.
push_tag() {
echo "build-image: pushing ${1}"
skopeo copy --dest-tls-verify=false "oci-archive:${TAR_FILE}" "docker://${1}"
}
push_tag "$REF"
if [ "$PUSH_LATEST" = "1" ]; then
push_tag "${REGISTRY}/${IMAGE}:latest"
fi
if [ -n "$VERSION_TAG" ]; then
push_tag "${REGISTRY}/${IMAGE}:${VERSION_TAG}"
fi
echo "✓ build-image: ${REF} pushed"
+39 -31
View File
@@ -58,41 +58,42 @@ jobs:
REF="${{ github.ref }}"
if [[ "$REF" == refs/tags/v* ]]; then
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "version=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
else
# The ldflags version stamp: git tag on a v* build, else short
# sha. Computed in shell rather than as a fallback expression in
# YAML, so it cannot depend on how the runner treats an empty step
# output -- a wrong VERSION would be baked into the binary silently.
#
# NOTE: no expression syntax in this comment, deliberately. act
# interpolates a run: block INCLUDING its comments, so prose
# containing that syntax is evaluated as an expression -- it failed
# with "Unavailable context: a", the step wrote no outputs, and the
# deploy job committed an empty image tag that broke Flux
# estate-wide (infra 51ceb7c).
echo "version=${SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Build and push to local registry
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
# Stamp the real build version: the git tag on a v* build, else the short sha.
VERSION="${{ steps.meta.outputs.version-tag }}"
[ -z "$VERSION" ] && VERSION="${{ steps.meta.outputs.sha-tag }}"
buildah build \
--build-arg VERSION="${VERSION}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
.
buildah push --tls-verify=false ${REF}
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
buildah push --tls-verify=false \
${REF} \
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "✓ Image pushed to ${REF}"
# infra#309: implementation lives in .gitea/build-image.sh, a
# byte-identical copy of mathias/infra's ci-templates/build-image.sh.
# Do not edit it here -- edit the canonical file, run `task ci:sync`,
# and infra's drift gate names any repo still carrying an old copy.
env:
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
GIT_SHA: ${{ github.sha }}
IMAGE_SOURCE: ${{ github.repositoryUrl }}
BUILD_ARGS: VERSION=${{ steps.meta.outputs.version }}
run: bash .gitea/build-image.sh
- name: Smoke test
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
sudo k3s ctr images pull --plain-http ${REF}
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /gitea-mcp 2>&1 || true)
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
echo "$OUTPUT" | grep -q "gitea-mcp" \
&& echo "✓ Smoke test passed" \
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
# No smoke test here. The old one ran `sudo k3s ctr run` against the host's
# containerd: written for the Arch host runner, impossible in the
# containerized runner (infra#233) which has no sudo, no ctr and no
# containerd socket -- it failed with `sudo: command not found` the moment
# infra#241's buildctl migration let the build reach it. The deploy job's
# `Verify rollout` + `Confirm pod running new image` cover the same ground
# more strongly, which is why tapir dropped its smoke test too (infra#241).
# ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
deploy:
@@ -108,6 +109,13 @@ jobs:
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
# infra#311: fail on an empty tag BEFORE patching anything. The
# grep verification further down CANNOT catch it -- with IMAGE_TAG
# empty its pattern matches the very corruption it exists to reject.
# The result is `image: <repo>:` in the manifest, which is invalid
# YAML, so Flux fails the ENTIRE apps Kustomization and every app
# stops reconciling, not just this one. That happened on 2026-08-25.
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
+90 -21
View File
@@ -48,14 +48,30 @@ These rules apply to every task across every project, regardless of harness.
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
the tiered merge model in `assessor-loop`
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
what it sells instead of merging everything on green and claiming otherwise.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
collisions between parallel sessions: two were not branching problems at all — a
generated artifact written to the same path by both (branching turns that into a
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
push resolved by rebase in seconds. Use a worktree per session on shared repos and
timestamp generated artifacts. Do not reach for a branch.
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
issues, review comments live on a thread separate from the issue's, and a
merged-and-deleted branch cannot be amended (brain:
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
**Exception — external contributor or client four-eyes requirement:** Use PR flow
when a human reviewer outside the project is required. Document the reason in
PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted:
@@ -73,6 +89,26 @@ These rules apply to every task across every project, regardless of harness.
- Surface the brain entries and issue numbers in the closing summary so the
trail is auditable.
**Chat is not a durable store. Never rely on Mathias remembering anything.**
Every gap, caveat, risk, open question and next step must land in one of four
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
lost the moment the session closes — and this estate has repeatedly proved that a
remembered convention is an unreliable control.
This applies hardest to what is tempting to leave as a closing remark: "worth
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you.
## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
## Default stack
| Layer | Default | Fallback | Last resort |
@@ -83,7 +119,7 @@ These rules apply to every task across every project, regardless of harness.
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
@@ -92,7 +128,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
@@ -130,10 +171,10 @@ Three machines on Tailscale:
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
- **Networking**: Tailscale mesh
## Project landscape
@@ -229,10 +270,11 @@ When working on a project tagged with a client name:
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
re-add adapters for them:
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
- Any other tool that reads `AGENTS.md`, which is the de facto convention
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
@@ -244,15 +286,29 @@ Canonical sources of truth:
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
you touch the repo, along with their entries in that repo's `task check`
assertion.
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
2. On any other host, `git pull` brings both. Which file a harness reads does
NOT split cleanly by lookup strategy, so do not reason from the strategy:
- `CLAUDE.md` — Claude Code (walks up the tree)
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
up** from cwd and takes the first `AGENTS.md` it finds
(user-level `~/.vibe/AGENTS.md`)
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
any harness, it is for pasting
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
inside that repo, so this works — but by construction, not by luck.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
@@ -263,7 +319,20 @@ unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
**Skill trigger table — load before starting, not after getting stuck:**
+2 -4
View File
@@ -27,12 +27,12 @@ tasks:
echo "✓ context sync: skipped in CI"
else
bash scripts/context-sync.sh
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
if [ -n "$drift" ]; then
echo "ERROR: derived adapters drifted from canonical context." >&2
echo "$drift" >&2
echo "" >&2
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
echo " git commit -m 'chore: re-sync context adapters'" >&2
exit 1
fi
@@ -58,5 +58,3 @@ tasks:
cmds: [bash scripts/context-sync.sh claude]
context:sync:agents:
cmds: [bash scripts/context-sync.sh agents]
context:sync:cursor:
cmds: [bash scripts/context-sync.sh cursor]
+77
View File
@@ -0,0 +1,77 @@
// Command gitea-admin-mcp is a LOCAL-ONLY stdio MCP server exposing exactly
// one tool: mint_service_account_token.
//
// This exists because dispatch#67 fix #2 hit a hard wall: Gitea's own
// token-creation and admin-user endpoints reject token auth outright — an
// existing API token cannot create another token, or a new account, by
// design (a leaked token being usable to escalate would defeat the point of
// scoping it). The only way through is real basic auth as an actual admin,
// which a sandboxed agent session cannot safely hold. This binary is meant
// to be spawned by a LOCAL Claude Code session (or run by hand) on a machine
// with real, interactive 1Password access — configured as a stdio MCP
// server in .mcp.json, or invoked directly.
//
// Deliberately NOT part of cmd/gitea-mcp (the shared HTTP server): the
// capability here (reset a target account's password, mint it a token) is
// far too dangerous to ever expose over the network, even behind OIDC.
// internal/localtools is a separate package specifically so it can never be
// wired into that server's RegisterAll by accident.
//
// Required env (fetch admin credentials just-in-time, e.g. via
// `op run --env-file ~/.op-env -- gitea-admin-mcp`):
//
// GITEA_BASE_URL e.g. https://git.d-ma.be
// GITEA_ADMIN_USERNAME a real site-admin account's username
// GITEA_ADMIN_PASSWORD that account's real password
// OP_VAULT 1Password vault to store minted tokens in
// (default: AgentSecrets, per the estate's agent-governed
// ops-secrets policy)
package main
import (
"context"
"fmt"
"log/slog"
"os"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
// Logs to stderr, never stdout — stdout is the MCP JSON-RPC channel, and
// a stray log line there would corrupt the protocol stream.
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
baseURL := os.Getenv("GITEA_BASE_URL")
adminUser := os.Getenv("GITEA_ADMIN_USERNAME")
adminPass := os.Getenv("GITEA_ADMIN_PASSWORD")
if baseURL == "" || adminUser == "" || adminPass == "" {
fmt.Fprintln(os.Stderr, "GITEA_BASE_URL, GITEA_ADMIN_USERNAME, and GITEA_ADMIN_PASSWORD are all required")
os.Exit(1)
}
b := adminbootstrap.Bootstrapper{
BaseURL: baseURL,
AdminUsername: adminUser,
AdminPassword: adminPass,
Secrets: localtools.OpSecretStore{Vault: envOr("OP_VAULT", "AgentSecrets")},
}
reg := registry.New()
reg.Register(localtools.NewMintServiceAccountToken(b))
if err := mcp.ServeStdio(context.Background(), reg, os.Stdin, os.Stdout); err != nil {
logger.Error("serve stdio", "err", err)
os.Exit(1)
}
}
+92
View File
@@ -0,0 +1,92 @@
# gitea-admin-mcp
A separate, local-only stdio MCP server. One tool: `mint_service_account_token`.
## Why this exists
dispatch#67 fix #2 needed a read-only CI credential for a dedicated service
account, and hit a hard wall: Gitea's own `POST /users/{u}/tokens` and
`POST /admin/users` both reject token auth outright —
{"message":"auth required"}
{"message":"token does not have at least one of required scope(s), required=[write:admin], ..."}
— by design: an existing API token cannot mint another token, or create an
account, no matter its scope. The only way through either endpoint is real
basic auth as an actual site admin. A sandboxed agent session has no
interactive password and shouldn't be handed one — that's broader exposure
than the job needs. This binary is meant to run where real admin credentials
already exist just-in-time: your own machine, via `op run`.
## What it does
`mint_service_account_token` does the whole bootstrap in one call:
1. Creates the named account if it doesn't exist (admin basic auth), or
resets its password if it does. **This is why `confirm_service_account`
is required and checked first** — the tool unconditionally (re)sets
whatever password the target account has, which is correct for a
dedicated bot account and would be destructive pointed at a real person's
login by mistake.
2. Mints a scoped token for that account, authenticating as the account
itself (Gitea requires this specifically — an admin cannot mint a token
on another account's behalf).
3. Grants the account read-only collaborator access on any named
`owner/repo` targets (admin action, least privilege).
4. Stores the token in 1Password via `op item create`, value passed on
stdin — never as a literal argv element, matching the estate's own
secret-handling rule. Returns only the `op://` reference, never the
token value itself, to the caller.
## Setup
Deliberately **not** wired into `cmd/gitea-mcp` (the shared HTTP server) —
this capability is too dangerous to expose over the network, even behind
OIDC. `internal/localtools` is a separate package specifically so it can
never be wired into that server's `RegisterAll` by accident.
Build:
```
go build -o gitea-admin-mcp ./cmd/gitea-admin-mcp
```
Configure as a local stdio MCP server (Claude Code `.mcp.json`, run where
your real `op` session lives):
```json
{
"mcpServers": {
"gitea-admin": {
"command": "op",
"args": ["run", "--env-file", "~/.op-env", "--", "/path/to/gitea-admin-mcp"]
}
}
}
```
Required env (see `~/.op-env`):
| Var | Meaning |
|---|---|
| `GITEA_BASE_URL` | e.g. `https://git.d-ma.be` |
| `GITEA_ADMIN_USERNAME` | a real site-admin account |
| `GITEA_ADMIN_PASSWORD` | that account's real password |
| `OP_VAULT` | 1Password vault for minted tokens (default `AgentSecrets`, per the estate's agent-governed ops-secrets policy) |
## Example call
```json
{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": ["read:repository", "read:issue"],
"token_name": "dispatch-ci-readonly",
"secret_name": "gitea-dispatch-bot-ci-readonly",
"grant_read_repos": ["mathias/cobalt-dingo"],
"confirm_service_account": true
}
```
Result carries `SecretRef` (e.g. `op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential`)
and `GrantedRepos` — never the token or either password.
+284
View File
@@ -0,0 +1,284 @@
// Package adminbootstrap mints a scoped Gitea access token for a dedicated
// service/bot account, end to end, in one auditable call.
//
// dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
// Gitea's own token-creation and admin-user endpoints reject token auth
// outright (POST /users/{u}/tokens and POST /admin/users both return
// "auth required" / a required-scope error for an existing token, by
// design — a leaked token being usable to mint further tokens would be a
// privilege-escalation footgun). Neither endpoint has a token-scoped
// workaround. The only way through is real basic auth as an actual admin.
//
// That is exactly what a sandboxed agent session cannot safely have — no
// interactive password, and handing one to a session would be broader
// exposure than the job needs. This package is meant to run LOCALLY, where
// real admin credentials exist just-in-time (1Password, `op run`), as a
// small stdio MCP tool (see cmd/gitea-admin-mcp) rather than ad hoc curl.
package adminbootstrap
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// HTTPDoer is the one method this package needs from *http.Client — narrow
// on purpose, so tests inject a fake transport instead of hitting a network.
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// SecretStore persists the minted token somewhere durable and returns a
// reference safe to log or display — never the value itself. The real
// implementation (cmd/gitea-admin-mcp) shells out to the local `op` CLI;
// tests use an in-memory fake.
type SecretStore interface {
Store(ctx context.Context, name, value string) (ref string, err error)
}
// RepoRef names a repo to grant the new account read-only collaborator
// access to — least privilege: a service account should see only the repos
// it actually operates against, not every private repo on the instance.
type RepoRef struct {
Owner string
Name string
}
// Request describes one bootstrap. ConfirmServiceAccount is required and
// checked first: this package (re)sets whatever password Username already
// has, which is correct and safe for a dedicated bot account and dangerous
// for anything else.
type Request struct {
Username string
Email string
FullName string
Scopes []string
TokenName string
SecretName string
GrantReadRepos []RepoRef
ConfirmServiceAccount bool
}
// Result reports what happened. SecretRef, never the token value, is what a
// caller (or an MCP tool's text response) should ever surface.
type Result struct {
Username string
Created bool // false if the account already existed (password was reset, not created)
SecretRef string
GrantedRepos []string
}
// Bootstrapper holds the real admin credentials and where to reach Gitea —
// constructed once per invocation, never persisted.
type Bootstrapper struct {
BaseURL string
AdminUsername string
AdminPassword string
HTTP HTTPDoer // nil defaults to http.DefaultClient
Secrets SecretStore
}
func (b Bootstrapper) httpClient() HTTPDoer {
if b.HTTP != nil {
return b.HTTP
}
return http.DefaultClient
}
// Run creates (or resets the password of) Username, mints it a scoped
// token, grants it read-only access to GrantReadRepos, stores the token via
// Secrets, and returns without ever exposing the token value or either
// password to the caller.
func (b Bootstrapper) Run(ctx context.Context, req Request) (Result, error) {
if !req.ConfirmServiceAccount {
return Result{}, fmt.Errorf("refusing: ConfirmServiceAccount must be true — this resets %q's password unconditionally, which is only safe for a dedicated service/bot account", req.Username)
}
created, err := b.userExists(ctx, req.Username)
if err != nil {
return Result{}, fmt.Errorf("check existing account: %w", err)
}
created = !created // userExists returns true if found; Result.Created means "we created it"
botPassword, err := randomPassword()
if err != nil {
return Result{}, fmt.Errorf("generate account password: %w", err)
}
if created {
if err := b.createUser(ctx, req, botPassword); err != nil {
return Result{}, fmt.Errorf("create account: %w", err)
}
} else {
if err := b.resetPassword(ctx, req.Username, botPassword); err != nil {
return Result{}, fmt.Errorf("reset existing account's password: %w", err)
}
}
token, err := b.mintToken(ctx, req.Username, botPassword, req.TokenName, req.Scopes)
if err != nil {
return Result{}, fmt.Errorf("mint token: %w", err)
}
var granted []string
for _, r := range req.GrantReadRepos {
if err := b.grantReadAccess(ctx, r, req.Username); err != nil {
return Result{}, fmt.Errorf("grant read access on %s/%s: %w", r.Owner, r.Name, err)
}
granted = append(granted, r.Owner+"/"+r.Name)
}
ref, err := b.Secrets.Store(ctx, req.SecretName, token)
if err != nil {
return Result{}, fmt.Errorf("store token: %w", err)
}
return Result{
Username: req.Username,
Created: created,
SecretRef: ref,
GrantedRepos: granted,
}, nil
}
func randomPassword() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func (b Bootstrapper) userExists(ctx context.Context, username string) (bool, error) {
resp, err := b.doAdmin(ctx, http.MethodGet, "/api/v1/users/"+username, nil)
if err != nil {
return false, err
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected status %d checking account existence", resp.StatusCode)
}
}
func (b Bootstrapper) createUser(ctx context.Context, req Request, password string) error {
body := map[string]any{
"username": req.Username,
"email": req.Email,
"password": password,
"must_change_password": false,
}
if req.FullName != "" {
body["full_name"] = req.FullName
}
resp, err := b.doAdmin(ctx, http.MethodPost, "/api/v1/admin/users", body)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
func (b Bootstrapper) resetPassword(ctx context.Context, username, password string) error {
resp, err := b.doAdmin(ctx, http.MethodPatch, "/api/v1/admin/users/"+username, map[string]any{
"password": password,
})
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
// mintToken authenticates as the SERVICE ACCOUNT itself (not the admin) —
// Gitea's token-creation endpoint requires basic auth as the token's owner,
// and an admin's own credentials cannot mint on another account's behalf.
func (b Bootstrapper) mintToken(ctx context.Context, username, password, tokenName string, scopes []string) (string, error) {
raw, err := json.Marshal(map[string]any{"name": tokenName, "scopes": scopes})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(b.BaseURL, "/")+"/api/v1/users/"+username+"/tokens", bytes.NewReader(raw))
if err != nil {
return "", err
}
req.SetBasicAuth(username, password)
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient().Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return "", statusErr(resp)
}
var parsed struct {
SHA1 string `json:"sha1"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
if parsed.SHA1 == "" {
return "", fmt.Errorf("token response carried no sha1 value")
}
return parsed.SHA1, nil
}
// grantReadAccess is an admin action (adding a collaborator to a repo the
// admin owns), so it uses admin auth, not the new account's own.
func (b Bootstrapper) grantReadAccess(ctx context.Context, r RepoRef, username string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", r.Owner, r.Name, username)
resp, err := b.doAdmin(ctx, http.MethodPut, path, map[string]any{"permission": "read"})
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
func (b Bootstrapper) doAdmin(ctx context.Context, method, path string, body map[string]any) (*http.Response, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(b.BaseURL, "/")+path, reader)
if err != nil {
return nil, err
}
req.SetBasicAuth(b.AdminUsername, b.AdminPassword)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return b.httpClient().Do(req)
}
func statusErr(resp *http.Response) error {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("gitea returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
@@ -0,0 +1,258 @@
package adminbootstrap_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This exists to close dispatch#67 fix #2's blocker: minting a scoped Gitea
// token needs basic auth (POST /users/{u}/tokens and POST /admin/users both
// reject token auth outright, confirmed live against git.d-ma.be — an
// existing token cannot mint another token, by design). A sandboxed agent
// session has no interactive password, so this package is meant to run
// locally, where a real admin password is available just-in-time (1Password,
// `op run`), and to do the whole bootstrap in one auditable call rather than
// leaving it as ad hoc curl.
//
// Design choice, made explicit rather than silently assumed: this tool
// ALWAYS (re)sets the target account's password via admin basic auth before
// minting, whether the account is new or already existed. That is safe and
// expected for a dedicated service/bot account (its password is a mechanism
// to reach the token endpoint, never a human login credential) and would be
// dangerous pointed at a real person's account by mistake -- which is why
// Request.ConfirmServiceAccount exists and Run refuses to proceed without it.
type call struct {
method, path string
body map[string]any
authUser string // basic-auth username used for this call, "" if token/none
authPass string
}
type fakeTransport struct {
t *testing.T
responses map[string]fakeResponse // key: "METHOD path"
calls []call
}
type fakeResponse struct {
status int
body string
}
func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var body map[string]any
if req.Body != nil {
raw, _ := io.ReadAll(req.Body)
if len(raw) > 0 {
require.NoError(f.t, json.Unmarshal(raw, &body))
}
}
user, pass, _ := req.BasicAuth()
f.calls = append(f.calls, call{method: req.Method, path: req.URL.Path, body: body, authUser: user, authPass: pass})
key := req.Method + " " + req.URL.Path
r, ok := f.responses[key]
if !ok {
f.t.Fatalf("unscripted request: %s (have: %v)", key, keys(f.responses))
}
return &http.Response{
StatusCode: r.status,
Body: io.NopCloser(strings.NewReader(r.body)),
Header: make(http.Header),
}, nil
}
func keys(m map[string]fakeResponse) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// fakeSecretStore records what would have been written to 1Password, without
// ever touching it -- and, critically, asserts the token value itself never
// leaks into anything but this one call.
type fakeSecretStore struct {
stored map[string]string
}
func (f *fakeSecretStore) Store(_ context.Context, name, value string) (string, error) {
if f.stored == nil {
f.stored = map[string]string{}
}
f.stored[name] = value
return "op://AgentSecrets/" + name + "/token", nil
}
func newBootstrapper(t *testing.T, responses map[string]fakeResponse) (adminbootstrap.Bootstrapper, *fakeTransport, *fakeSecretStore) {
ft := &fakeTransport{t: t, responses: responses}
fs := &fakeSecretStore{}
return adminbootstrap.Bootstrapper{
BaseURL: "http://fake.gitea",
AdminUsername: "mathias",
AdminPassword: "admin-pass-not-a-real-secret",
HTTP: &http.Client{Transport: ft},
Secrets: fs,
}, ft, fs
}
func TestRun_CreatesNewAccountMintsTokenAndStoresIt(t *testing.T) {
b, ft, fs := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 201, body: `{"login":"dispatch-bot"}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_abc123"}`},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository", "read:issue"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.True(t, result.Created)
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result.SecretRef)
assert.Equal(t, "tok_abc123", fs.stored["gitea-dispatch-bot-ci-readonly"])
// The account-creation call must use the ADMIN's basic auth, never the
// fresh account's own (unknown at that point) password.
create := findCall(t, ft.calls, "POST", "/api/v1/admin/users")
assert.Equal(t, "mathias", create.authUser)
assert.Equal(t, "dispatch-bot", create.body["username"])
assert.Equal(t, "dispatch@d-ma.be", create.body["email"])
assert.Equal(t, false, create.body["must_change_password"])
// The token-mint call must use basic auth AS THE NEW ACCOUNT -- Gitea
// requires this specifically (an admin token cannot mint on someone
// else's behalf) -- with the password this package generated and set,
// never the admin's own password.
mint := findCall(t, ft.calls, "POST", "/api/v1/users/dispatch-bot/tokens")
assert.Equal(t, "dispatch-bot", mint.authUser)
assert.NotEqual(t, "admin-pass-not-a-real-secret", mint.authPass)
assert.NotEmpty(t, mint.authPass)
assert.Equal(t, "dispatch-ci-readonly", mint.body["name"])
assert.ElementsMatch(t, []any{"read:repository", "read:issue"}, mint.body["scopes"])
}
func TestRun_ExistingAccountGetsPasswordResetNotRecreated(t *testing.T) {
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 200, body: `{"login":"dispatch-bot"}`},
"PATCH /api/v1/admin/users/dispatch-bot": {status: 200, body: `{}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_new"}`},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.False(t, result.Created, "must report the account as pre-existing, not newly created")
// Must PATCH (reset password), never POST (create) for an existing account.
assert.Empty(t, filterCalls(ft.calls, "POST", "/api/v1/admin/users"))
patch := findCall(t, ft.calls, "PATCH", "/api/v1/admin/users/dispatch-bot")
assert.Equal(t, "mathias", patch.authUser, "the reset must be done as the real admin")
assert.NotEmpty(t, patch.body["password"])
}
func TestRun_RefusesWithoutExplicitServiceAccountConfirmation(t *testing.T) {
// Guard against pointing this at a real person's account by accident --
// this tool resets whatever password the target account has.
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{})
_, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "mathias",
Email: "mthbqv@gmail.com",
Scopes: []string{"read:repository"},
TokenName: "whatever",
SecretName: "whatever",
// ConfirmServiceAccount left false/unset
})
require.Error(t, err)
assert.Contains(t, err.Error(), "ConfirmServiceAccount")
assert.Empty(t, ft.calls, "must not make any request at all without confirmation")
}
func TestRun_GrantsReadOnlyCollaboratorAccessViaAdminAuth(t *testing.T) {
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 201, body: `{}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok"}`},
"PUT /api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot": {status: 204, body: ``},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
GrantReadRepos: []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}},
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.Equal(t, []string{"mathias/cobalt-dingo"}, result.GrantedRepos)
grant := findCall(t, ft.calls, "PUT", "/api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot")
assert.Equal(t, "mathias", grant.authUser, "granting collaborator access is an admin action, not the new account's own")
assert.Equal(t, "read", grant.body["permission"])
}
func TestRun_FailsClosedOnUpstreamError(t *testing.T) {
b, _, fs := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 500, body: `{"message":"internal error"}`},
})
_, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "x",
SecretName: "x",
ConfirmServiceAccount: true,
})
require.Error(t, err)
assert.Empty(t, fs.stored, "nothing must be written to the secret store if the flow didn't complete")
}
func findCall(t *testing.T, calls []call, method, path string) call {
t.Helper()
for _, c := range calls {
if c.method == method && c.path == path {
return c
}
}
t.Fatalf("no call found: %s %s (have: %+v)", method, path, calls)
return call{}
}
func filterCalls(calls []call, method, path string) []call {
var out []call
for _, c := range calls {
if c.method == method && c.path == path {
out = append(out, c)
}
}
return out
}
+14 -2
View File
@@ -1,6 +1,11 @@
package allowlist
import "fmt"
import (
"context"
"fmt"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
)
type Allowlist struct {
owners map[string]struct{}
@@ -14,10 +19,17 @@ func New(owners []string) *Allowlist {
return &Allowlist{owners: m}
}
func (a *Allowlist) Check(owner string) error {
// Check gates owner access to the static list — except for a caller
// authenticated with their own Gitea PAT (pass-through, gitea-mcp#59), whose
// access Gitea's own permission model already gates more precisely than a
// coarse owner name list ever could.
func (a *Allowlist) Check(ctx context.Context, owner string) error {
if owner == "" {
return fmt.Errorf("owner required")
}
if _, ok := gitea.TokenFromContext(ctx); ok {
return nil
}
if _, ok := a.owners[owner]; !ok {
return fmt.Errorf("owner %q not in allowlist", owner)
}
+24 -4
View File
@@ -1,16 +1,36 @@
package allowlist_test
import (
"context"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"git.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
)
func TestAllowlistCheck(t *testing.T) {
a := allowlist.New([]string{"mathias", "acme"})
assert.NoError(t, a.Check("mathias"))
assert.NoError(t, a.Check("acme"))
assert.Error(t, a.Check("evil"))
assert.Error(t, a.Check(""))
ctx := context.Background()
assert.NoError(t, a.Check(ctx, "mathias"))
assert.NoError(t, a.Check(ctx, "acme"))
assert.Error(t, a.Check(ctx, "evil"))
assert.Error(t, a.Check(ctx, ""))
}
// A caller authenticated with their own Gitea PAT (pass-through, gitea-mcp#59)
// is gated by Gitea's own permission model, not the MCP's static owner list —
// otherwise a legitimate second user could never touch their own repos.
func TestAllowlistCheckTrustsPassthroughAuthenticatedCaller(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.NoError(t, a.Check(ctx, "someone-else"))
}
// Empty owner is a structural input error, not an authz question — still
// rejected even on the pass-through path.
func TestAllowlistCheckStillRejectsEmptyOwnerOnPassthrough(t *testing.T) {
a := allowlist.New([]string{"mathias"})
ctx := gitea.WithToken(context.Background(), "someone-elses-pat")
assert.Error(t, a.Check(ctx, ""))
}
@@ -0,0 +1,100 @@
// Package localtools holds MCP tools that must never be reachable from the
// shared HTTP gitea-mcp server (RegisterAll, cmd/gitea-mcp). Each tool here
// carries a capability too dangerous to expose network-side, even behind
// OIDC — mint_service_account_token resets whatever password its target
// account has. Only cmd/gitea-admin-mcp, a separate local-only stdio binary,
// imports this package.
package localtools
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// bootstrapper is the one method this tool needs — narrow so tests inject a
// fake instead of a real adminbootstrap.Bootstrapper.
type bootstrapper interface {
Run(ctx context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error)
}
type MintServiceAccountToken struct {
b bootstrapper
}
func NewMintServiceAccountToken(b bootstrapper) *MintServiceAccountToken {
return &MintServiceAccountToken{b: b}
}
func (t *MintServiceAccountToken) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "mint_service_account_token",
Description: "Create (or reset the password of) a dedicated Gitea " +
"service/bot account, mint it a scoped access token, optionally " +
"grant it read-only collaborator access on named repos, and store " +
"the token via 1Password. Requires confirm_service_account=true: " +
"this resets whatever password the target account currently has, " +
"which is only safe for a dedicated bot account, never a real " +
"person's login.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"username":{"type":"string"},
"email":{"type":"string"},
"full_name":{"type":"string"},
"scopes":{"type":"array","items":{"type":"string"}},
"token_name":{"type":"string"},
"secret_name":{"type":"string","description":"name to store the minted token under"},
"grant_read_repos":{"type":"array","items":{"type":"string"},"description":"owner/repo strings"},
"confirm_service_account":{"type":"boolean"}
},
"required":["username","email","scopes","token_name","secret_name","confirm_service_account"]
}`),
}
}
type mintArgs struct {
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
Scopes []string `json:"scopes"`
TokenName string `json:"token_name"`
SecretName string `json:"secret_name"`
GrantReadRepos []string `json:"grant_read_repos"`
ConfirmServiceAccount bool `json:"confirm_service_account"`
}
func (t *MintServiceAccountToken) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args mintArgs
if err := json.Unmarshal(raw, &args); err != nil {
return nil, fmt.Errorf("parse arguments: %w", err)
}
repos := make([]adminbootstrap.RepoRef, 0, len(args.GrantReadRepos))
for _, r := range args.GrantReadRepos {
owner, name, ok := strings.Cut(r, "/")
if !ok || owner == "" || name == "" {
return nil, fmt.Errorf("grant_read_repos entry %q must be owner/repo", r)
}
repos = append(repos, adminbootstrap.RepoRef{Owner: owner, Name: name})
}
result, err := t.b.Run(ctx, adminbootstrap.Request{
Username: args.Username,
Email: args.Email,
FullName: args.FullName,
Scopes: args.Scopes,
TokenName: args.TokenName,
SecretName: args.SecretName,
GrantReadRepos: repos,
ConfirmServiceAccount: args.ConfirmServiceAccount,
})
if err != nil {
return nil, err
}
return json.Marshal(result)
}
@@ -0,0 +1,110 @@
package localtools_test
import (
"context"
"encoding/json"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This tool lives in its own package, never internal/tools, on purpose: it
// resets whatever password its target account has, which is far too
// dangerous a capability to risk it ever being wired into RegisterAll and
// exposed on the shared HTTP gitea-mcp server. Only cmd/gitea-admin-mcp (a
// separate, local-only, stdio binary) imports this package.
type fakeBootstrapper struct {
gotReq adminbootstrap.Request
result adminbootstrap.Result
err error
}
func (f *fakeBootstrapper) Run(_ context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error) {
f.gotReq = req
return f.result, f.err
}
func TestMintServiceAccountToken_ParsesArgsAndCallsBootstrapper(t *testing.T) {
fb := &fakeBootstrapper{result: adminbootstrap.Result{
Username: "dispatch-bot", Created: true,
SecretRef: "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token",
}}
tool := localtools.NewMintServiceAccountToken(fb)
args, _ := json.Marshal(map[string]any{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": []string{"read:repository", "read:issue"},
"token_name": "dispatch-ci-readonly",
"secret_name": "gitea-dispatch-bot-ci-readonly",
"grant_read_repos": []string{"mathias/cobalt-dingo"},
"confirm_service_account": true,
})
out, err := tool.Call(context.Background(), args)
require.NoError(t, err)
assert.Equal(t, "dispatch-bot", fb.gotReq.Username)
assert.Equal(t, []string{"read:repository", "read:issue"}, fb.gotReq.Scopes)
assert.Equal(t, true, fb.gotReq.ConfirmServiceAccount)
require.Equal(t, []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}}, fb.gotReq.GrantReadRepos)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result["SecretRef"])
// The tool's own output must never carry a raw token or password field --
// adminbootstrap.Result structurally can't (it has none), which this pins.
_, hasToken := result["Token"]
_, hasPassword := result["Password"]
assert.False(t, hasToken)
assert.False(t, hasPassword)
}
func TestMintServiceAccountToken_RejectsMalformedRepoRef(t *testing.T) {
fb := &fakeBootstrapper{}
tool := localtools.NewMintServiceAccountToken(fb)
args, _ := json.Marshal(map[string]any{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": []string{"read:repository"},
"token_name": "x",
"secret_name": "x",
"grant_read_repos": []string{"not-owner-slash-repo"},
"confirm_service_account": true,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
assert.Contains(t, err.Error(), "owner/repo")
}
func TestMintServiceAccountToken_PropagatesBootstrapperError(t *testing.T) {
fb := &fakeBootstrapper{err: assert.AnError}
tool := localtools.NewMintServiceAccountToken(fb)
args, _ := json.Marshal(map[string]any{
"username": "dispatch-bot",
"email": "dispatch@d-ma.be",
"scopes": []string{"read:repository"},
"token_name": "x",
"secret_name": "x",
"confirm_service_account": true,
})
_, err := tool.Call(context.Background(), args)
require.Error(t, err)
}
func TestMintServiceAccountToken_Descriptor(t *testing.T) {
tool := localtools.NewMintServiceAccountToken(&fakeBootstrapper{})
d := tool.Descriptor()
assert.Equal(t, "mint_service_account_token", d.Name)
assert.NotEmpty(t, d.Description)
}
+50
View File
@@ -0,0 +1,50 @@
package localtools
import (
"context"
"fmt"
"os/exec"
"strings"
)
// OpSecretStore implements adminbootstrap.SecretStore against the local
// `op` (1Password) CLI. Run is injectable for tests; nil defaults to
// actually invoking `op`.
//
// The token value is passed via the child process's stdin, never as a
// literal argv element — matching the estate's own secret-handling rule
// (a value in argv lands in process listings and any transcript of the
// command). `credential[password]=-` is `op item create`'s documented way
// to read one field's value from stdin instead of the command line.
type OpSecretStore struct {
Vault string
Run func(ctx context.Context, stdin string, args ...string) error
}
func (s OpSecretStore) Store(ctx context.Context, name, value string) (string, error) {
run := s.Run
if run == nil {
run = runOp
}
args := []string{
"item", "create",
"--category=login",
"--vault=" + s.Vault,
"--title=" + name,
"credential[password]=-",
}
if err := run(ctx, value, args...); err != nil {
return "", fmt.Errorf("op item create: %w", err)
}
return fmt.Sprintf("op://%s/%s/credential", s.Vault, name), nil
}
func runOp(ctx context.Context, stdin string, args ...string) error {
cmd := exec.CommandContext(ctx, "op", args...)
cmd.Stdin = strings.NewReader(stdin)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package localtools_test
import (
"context"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The estate's own secret-handling rule: never place a literal secret in a
// command's argv — it lands in process listings and any transcript of the
// command. OpSecretStore feeds the token to `op` via stdin instead.
func TestOpSecretStore_PassesValueViaStdinNeverArgv(t *testing.T) {
var gotStdin string
var gotArgs []string
s := localtools.OpSecretStore{
Vault: "AgentSecrets",
Run: func(_ context.Context, stdin string, args ...string) error {
gotStdin = stdin
gotArgs = args
return nil
},
}
ref, err := s.Store(context.Background(), "gitea-dispatch-bot-ci-readonly", "tok_the_actual_secret_value")
require.NoError(t, err)
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/credential", ref)
assert.Equal(t, "tok_the_actual_secret_value", gotStdin)
for _, a := range gotArgs {
assert.NotContains(t, a, "tok_the_actual_secret_value",
"the secret value must never appear as a literal argv element")
}
assert.Contains(t, gotArgs, "--vault=AgentSecrets")
assert.Contains(t, gotArgs, "--title=gitea-dispatch-bot-ci-readonly")
}
func TestOpSecretStore_PropagatesRunError(t *testing.T) {
s := localtools.OpSecretStore{
Vault: "AgentSecrets",
Run: func(_ context.Context, _ string, _ ...string) error {
return assert.AnError
},
}
_, err := s.Store(context.Background(), "x", "y")
require.Error(t, err)
}
+95
View File
@@ -0,0 +1,95 @@
package mcp
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// ServeStdio runs the MCP JSON-RPC protocol over stdio: one JSON-RPC message
// per line on in, one response per line on out — the standard MCP stdio
// framing, and how Claude Code spawns a LOCAL MCP server as a subprocess.
//
// Built for gitea-admin-mcp (see cmd/gitea-admin-mcp), which has to run
// locally where real admin credentials exist just-in-time, rather than
// through the hosted HTTP server (server.go) a sandboxed agent session talks
// to. The method dispatch below intentionally mirrors handlePOST in
// server.go — same three methods, same error codes — so a tool written
// against registry.Tool behaves identically over either transport; it is
// kept as a separate small loop rather than a shared refactor of the HTTP
// handler, which mixes in http.ResponseWriter specifics this transport
// doesn't have.
func ServeStdio(ctx context.Context, reg *registry.Registry, in io.Reader, out io.Writer) error {
scanner := bufio.NewScanner(in)
scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) // 1 MiB max line, matches server.go's request cap
enc := json.NewEncoder(out)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
if werr := enc.Encode(NewErrorResponse(nil, -32700, "parse error", nil)); werr != nil {
return werr
}
continue
}
if req.ID == nil {
continue // notification — no response, per JSON-RPC 2.0
}
resp := dispatch(ctx, reg, req)
if err := enc.Encode(resp); err != nil {
return err
}
}
return scanner.Err()
}
// dispatch handles one already-parsed request. Mirrors server.go's
// handlePOST switch exactly (initialize / tools/list / tools/call / unknown).
func dispatch(ctx context.Context, reg *registry.Registry, req Request) Response {
if req.Method == "initialize" {
return NewResponse(req.ID, map[string]any{
"protocolVersion": ProtocolVersion,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "gitea-admin-mcp", "version": "0.1.0"},
})
}
switch req.Method {
case "tools/list":
return NewResponse(req.ID, map[string]any{"tools": reg.Tools()})
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil {
return NewErrorResponse(req.ID, -32602, "invalid params", nil)
}
out, err := reg.Dispatch(ctx, p.Name, p.Arguments)
if err != nil {
code := -32000
if errors.Is(err, registry.ErrToolNotFound) {
code = CodeNotFound
}
return NewErrorResponse(req.ID, code, err.Error(), nil)
}
return NewResponse(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(out)}},
})
default:
return NewErrorResponse(req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method), nil)
}
}
+115
View File
@@ -0,0 +1,115 @@
package mcp_test
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The existing MCP server (server.go) only speaks HTTP. gitea-admin-mcp needs
// to run as a LOCAL stdio server (Claude Code's local .mcp.json stdio server
// type, spawned as a subprocess with real 1Password/admin auth already in its
// environment) -- reusing the exact same registry.Tool interface and
// jsonrpc.go message types, one JSON-RPC message per line on stdin/stdout,
// which is the standard MCP stdio framing.
type echoTool struct{}
func (echoTool) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{Name: "echo", Description: "echoes its input"}
}
func (echoTool) Call(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
return args, nil
}
func TestServeStdio_Initialize(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
assert.EqualValues(t, 1, resp.ID)
assert.Nil(t, resp.Error)
}
func TestServeStdio_ToolsListAndCall(t *testing.T) {
reg := registry.New()
reg.Register(echoTool{})
in := strings.NewReader(
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n" +
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"hello":"world"}}}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 2, "one JSON-RPC response per line, in request order")
var listResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[0]), &listResp))
assert.EqualValues(t, 1, listResp.ID)
var callResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[1]), &callResp))
assert.EqualValues(t, 2, callResp.ID)
require.NotNil(t, callResp.Result)
}
func TestServeStdio_NotificationGetsNoResponse(t *testing.T) {
// A request with no "id" is a notification per JSON-RPC 2.0 -- must not
// write anything back, and must not stop the loop from processing what
// follows it.
reg := registry.New()
in := strings.NewReader(
`{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 1, "the notification must not produce a response line")
}
func TestServeStdio_UnknownMethodReturnsJSONRPCError(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"not/a/real/method"}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32601, resp.Error.Code)
}
func TestServeStdio_UnparseableLineReturnsParseError(t *testing.T) {
reg := registry.New()
in := strings.NewReader("not json at all\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err, "a malformed line must not crash the loop")
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32700, resp.Error.Code)
}
+1 -1
View File
@@ -46,7 +46,7 @@ func (t *BranchDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *BranchList) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *BranchProtectionGet) Call(ctx context.Context, raw json.RawMessage) (js
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Page < 1 {
@@ -111,7 +111,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
}
// Allowlist check first.
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *DirList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -52,7 +52,7 @@ func (t *FileDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Sha == "" {
+1 -1
View File
@@ -51,7 +51,7 @@ func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -57,7 +57,7 @@ func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.R
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueClose) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "closed")
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawM
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -56,7 +56,7 @@ func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *IssueEdit) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -43,7 +43,7 @@ func (t *IssueGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.GetIssue(ctx, args.Owner, args.Repo, args.Number)
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *IssueLabel) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.State == "" {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueListComments) Call(ctx context.Context, raw json.RawMessage) (json
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
comments, err := t.c.ListIssueComments(ctx, args.Owner, args.Repo, args.Number)
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *IssueReopen) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
iss, err := t.c.SetIssueState(ctx, args.Owner, args.Repo, args.Number, "open")
+1 -1
View File
@@ -43,7 +43,7 @@ func (t *LabelList) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
labels, err := t.c.ListLabels(ctx, args.Owner, args.Repo)
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -56,7 +56,7 @@ func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
+1 -1
View File
@@ -63,7 +63,7 @@ func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMe
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -44,7 +44,7 @@ func (t *PRGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage,
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -51,7 +51,7 @@ func (t *PRList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
state := args.State
+1 -1
View File
@@ -52,7 +52,7 @@ func (t *PRMerge) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
+1 -1
View File
@@ -55,7 +55,7 @@ func (t *ReleaseCreate) Call(ctx context.Context, raw json.RawMessage) (json.Raw
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
rel, err := t.c.CreateRelease(ctx, args.Owner, args.Repo, gitea.CreateReleaseArgs{
+1 -1
View File
@@ -53,7 +53,7 @@ func (t *RepoCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
createArgs := gitea.CreateRepoArgs{
+1 -1
View File
@@ -46,7 +46,7 @@ func (t *RepoDelete) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Confirm != args.Repo {
+1 -1
View File
@@ -38,7 +38,7 @@ func (t *RepoGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
r, err := t.c.GetRepo(ctx, args.Owner, args.Repo)
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 30)
+1 -1
View File
@@ -96,7 +96,7 @@ func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.Ra
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
switch args.Action {
+2 -2
View File
@@ -53,7 +53,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if args.Owner != "" {
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
}
@@ -75,7 +75,7 @@ func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if len(parts) != 2 {
continue
}
if t.a.Check(parts[0]) == nil {
if t.a.Check(ctx, parts[0]) == nil {
filtered = append(filtered, r)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoStatus) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if err := t.c.UpdateTopics(ctx, args.Owner, args.Repo, args.Topics); err != nil {
+1 -1
View File
@@ -45,7 +45,7 @@ func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessa
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
tree, err := t.c.GetTree(ctx, args.Owner, args.Repo, args.Ref, true)
+1 -1
View File
@@ -60,7 +60,7 @@ func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMes
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (t *TagCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMess
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Tag == "" {
+1 -1
View File
@@ -145,7 +145,7 @@ func (t *TBDShip) Call(ctx context.Context, raw json.RawMessage) (json.RawMessag
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Path == "" || args.Content == "" || args.Message == "" {
+1 -1
View File
@@ -57,7 +57,7 @@ func (t *WorkflowRunList) Call(ctx context.Context, raw json.RawMessage) (json.R
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 10)
+1 -1
View File
@@ -47,7 +47,7 @@ func (t *WorkflowRunStatus) Call(ctx context.Context, raw json.RawMessage) (json
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.RunID < 1 {
+1 -1
View File
@@ -62,7 +62,7 @@ func (t *WorkflowRunTrigger) Call(ctx context.Context, raw json.RawMessage) (jso
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
if err := t.a.Check(ctx, args.Owner); err != nil {
return nil, err
}
if args.Workflow == "" {
+5 -28
View File
@@ -5,6 +5,11 @@
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
# For everything else: concatenates root AGENT.md + project PROJECT.md
#
# Cursor and Aider adapters removed 2026-09-01 — neither is a supported harness
# any more. Supported: Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush,
# Pi. All read CLAUDE.md or AGENTS.md; system-prompt.txt remains for Open WebUI,
# Mods and raw API use.
#
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
# Task: task context:sync
#
@@ -95,30 +100,6 @@ generate_agents() {
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
}
# ── Cursor ───────────────────────────────────────────────────
generate_cursor() {
{
echo "# Cursor rules — auto-generated"
echo "# Do not edit. Run: task context:sync"
echo ""
root_block
cat "$PROJECT_FILE"
} > .cursorrules
echo " → .cursorrules (root + project)"
}
# ── Aider ────────────────────────────────────────────────────
generate_aider() {
{ root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md
if [ ! -f .aider.conf.yml ]; then
cat > .aider.conf.yml << 'YAML'
read: .aider.conventions.md
auto-commits: false
YAML
fi
echo " → .aider.conventions.md (root + project)"
}
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
generate_system_prompt() {
{
@@ -180,8 +161,6 @@ echo "Syncing project context from $PROJECT_FILE..."
if [ ${#ADAPTERS[@]} -eq 0 ]; then
generate_claude
generate_agents
generate_cursor
generate_aider
generate_system_prompt
generate_mcp
else
@@ -189,8 +168,6 @@ else
case "$adapter" in
claude) generate_claude ;;
agents) generate_agents ;;
cursor) generate_cursor ;;
aider) generate_aider ;;
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
mcp) generate_mcp ;;
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;