Compare commits
19 Commits
v0.2.0
...
4122e1f2ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 4122e1f2ae | |||
|
|
d74b196db1 | ||
|
|
103194a11a | ||
|
|
4c87856aec | ||
|
|
b4176c1dec | ||
|
|
174669b9f6 | ||
|
|
7a53935a9e | ||
|
|
3795800461 | ||
|
|
9987522f1a | ||
|
|
0e53738d9f | ||
|
|
91be18c100 | ||
|
|
efbbd37882 | ||
|
|
9d08352324 | ||
|
|
70173875d8 | ||
|
|
3784bcc31b | ||
|
|
f63605bdd0 | ||
|
|
c4d3735272 | ||
|
|
d8db786e27 | ||
|
|
923689afa5 |
2
.aider.conf.yml
Normal file
2
.aider.conf.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
read: .aider.conventions.md
|
||||||
|
auto-commits: false
|
||||||
250
.aider.conventions.md
Normal file
250
.aider.conventions.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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 | — | — |
|
||||||
|
|
||||||
|
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:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## 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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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://gitea.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:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### 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. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
79
.context/PROJECT.md
Normal file
79
.context/PROJECT.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 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://gitea.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:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### 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. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
22
.context/mcp.json
Normal file
22
.context/mcp.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://brain-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://infra-mcp.d-ma.be/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
257
.context/system-prompt.txt
Normal file
257
.context/system-prompt.txt
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
You are a coding assistant working on a specific project.
|
||||||
|
Follow all conventions from both the root agent context and project context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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 | — | — |
|
||||||
|
|
||||||
|
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:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## 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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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://gitea.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:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### 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. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
|
|
||||||
|
---
|
||||||
253
.cursorrules
Normal file
253
.cursorrules
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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 | — | — |
|
||||||
|
|
||||||
|
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:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## 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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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://gitea.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:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### 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. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
@@ -95,6 +95,7 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
environment: staging
|
||||||
steps:
|
steps:
|
||||||
- name: Update image tag in infra repo
|
- name: Update image tag in infra repo
|
||||||
env:
|
env:
|
||||||
|
|||||||
42
.skills/go-patterns/SKILL.md
Normal file
42
.skills/go-patterns/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
name: go-patterns
|
||||||
|
description: Go project patterns — endpoint checklist, error handling, HTMX responses, dependency policy. Use when writing Go code, adding endpoints, or reviewing Go PRs.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Go project patterns
|
||||||
|
|
||||||
|
## New endpoint checklist
|
||||||
|
1. Define request/response types in `types.go`
|
||||||
|
2. Write handler in `handlers.go` using `http.HandlerFunc`
|
||||||
|
3. Add route in `routes.go`
|
||||||
|
4. Write table-driven test in `handlers_test.go`
|
||||||
|
5. Run `task check` before committing
|
||||||
|
|
||||||
|
## Error handling pattern
|
||||||
|
```go
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("descriptiveOperation: %w", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Never log and return — do one or the other.
|
||||||
|
|
||||||
|
## HTMX response pattern
|
||||||
|
```go
|
||||||
|
func (h *Handler) ListItems(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.store.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to list items", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
h.templates.Render(w, "items/_list", items)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.templates.Render(w, "items/index", items)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency policy
|
||||||
|
- Prefer stdlib: `net/http`, `encoding/json`, `database/sql`
|
||||||
|
- Allowed without justification: `testify`, `slog`, `templ`, `sqlc`
|
||||||
|
- Needs justification in commit message: anything else
|
||||||
31
.skills/htmx-patterns/SKILL.md
Normal file
31
.skills/htmx-patterns/SKILL.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: htmx-patterns
|
||||||
|
description: HTMX conventions — default attributes, form patterns, validation errors, hypermedia-first API design. Use when writing HTMX templates or Go handlers that return HTML fragments.
|
||||||
|
---
|
||||||
|
|
||||||
|
# HTMX patterns
|
||||||
|
|
||||||
|
## Default attributes
|
||||||
|
Always include on interactive elements:
|
||||||
|
- `hx-indicator` for loading states
|
||||||
|
- `hx-swap="innerHTML"` as default (explicit over implicit)
|
||||||
|
- `hx-target` pointing to a specific ID, never `this` in production
|
||||||
|
|
||||||
|
## Form pattern
|
||||||
|
```html
|
||||||
|
<form hx-post="/items" hx-target="#item-list" hx-swap="beforeend" hx-indicator="#spinner">
|
||||||
|
<input type="text" name="title" required>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
<span id="spinner" class="htmx-indicator">...</span>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-sent validation errors
|
||||||
|
Return 422 with the error fragment, swap into the form's error container:
|
||||||
|
```html
|
||||||
|
hx-target-422="#form-errors"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prefer hypermedia over JSON
|
||||||
|
If the endpoint returns data for display, return an HTML fragment.
|
||||||
|
Only use JSON for machine-to-machine APIs or when a non-browser client needs it.
|
||||||
250
AGENTS.md
Normal file
250
AGENTS.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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 | — | — |
|
||||||
|
|
||||||
|
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:`), one concern per PR, PR describes *why* not *what*
|
||||||
|
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||||
|
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc are pre-approved; anything else needs justification in the commit message
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
When available, agents can query the shared knowledge base:
|
||||||
|
|
||||||
|
- **MCP**: `mcp://hyperguild.<TAILNET>.ts.net:3100/knowledge`
|
||||||
|
- **HTTP**: `http://hyperguild.<TAILNET>.ts.net:3100/api/v1/search`
|
||||||
|
|
||||||
|
<!-- TODO: replace <TAILNET> placeholder with the real Tailscale tailnet
|
||||||
|
name once hyperguild is deployed. Until then, agents that try to
|
||||||
|
reach the knowledge service on a host where it isn't running will
|
||||||
|
get DNS NXDOMAIN, which is the desired fail-loudly behavior. -->
|
||||||
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`
|
||||||
|
|
||||||
|
## 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 are available in `~/dev/.skills/`. Load on demand via the index.
|
||||||
|
|
||||||
|
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
||||||
|
|
||||||
|
Key skills:
|
||||||
|
- **TDD**: always write tests first — load `tdd` skill
|
||||||
|
- **Code Review**: load `code-review` skill before any review
|
||||||
|
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
||||||
|
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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://gitea.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:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### 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. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
79
CLAUDE.md
Normal file
79
CLAUDE.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 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://gitea.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:`
|
||||||
|
- Branch naming: `feat/short-description`, `fix/short-description`
|
||||||
|
- PRs: one concern per PR, description explains *why* not *what*
|
||||||
|
|
||||||
|
### 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. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
||||||
41
Taskfile.yml
41
Taskfile.yml
@@ -14,9 +14,42 @@ tasks:
|
|||||||
lint:
|
lint:
|
||||||
desc: Run golangci-lint
|
desc: Run golangci-lint
|
||||||
cmds: [golangci-lint run ./...]
|
cmds: [golangci-lint run ./...]
|
||||||
check:
|
vet:
|
||||||
desc: Lint, vet, and test (used by CI)
|
|
||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run ./...
|
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
- go test ./... -race -count=1
|
- govulncheck ./... || true
|
||||||
|
|
||||||
|
check:
|
||||||
|
desc: Run all checks (context freshness + lint + test + vet)
|
||||||
|
cmds:
|
||||||
|
- cmd: |
|
||||||
|
if [ -n "${CI:-}" ]; then
|
||||||
|
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)
|
||||||
|
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 " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
|
fi
|
||||||
|
- task: lint
|
||||||
|
- task: test
|
||||||
|
- task: vet
|
||||||
|
|
||||||
|
context:sync:
|
||||||
|
desc: Regenerate all harness-specific context files
|
||||||
|
cmds:
|
||||||
|
- bash scripts/context-sync.sh
|
||||||
|
|
||||||
|
context:sync:claude:
|
||||||
|
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]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -23,7 +25,14 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
|
||||||
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
ownerAllow := allowlist.New(cfg.AllowedOwners)
|
||||||
|
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
@@ -31,6 +40,7 @@ func main() {
|
|||||||
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
reg.Register(tools.NewRepoStatus(giteaClient, ownerAllow))
|
||||||
|
reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
|
||||||
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
reg.Register(tools.NewFileDelete(giteaClient, ownerAllow))
|
||||||
@@ -58,7 +68,11 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(auth.CallerMiddleware(mcpSrv)))
|
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||||
|
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
|
||||||
|
auth.CallerMiddleware(mcpSrv),
|
||||||
|
),
|
||||||
|
))
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
@@ -69,11 +83,14 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
payload := map[string]any{
|
||||||
_, _ = w.Write([]byte(`{"authorization_servers":[]}`))
|
"resource": cfg.MCPResourceURL,
|
||||||
})
|
"authorization_servers": []string{},
|
||||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
|
}
|
||||||
http.NotFound(w, r)
|
if cfg.DexIssuerURL != "" {
|
||||||
|
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
|
|||||||
18
go.mod
18
go.mod
@@ -2,10 +2,24 @@ module gitea.d-ma.be/mathias/gitea-mcp
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -1,11 +1,39 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
42
internal/auth/bearer.go
Normal file
42
internal/auth/bearer.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BearerMiddleware authenticates requests via the Authorization header.
|
||||||
|
//
|
||||||
|
// A request is allowed when:
|
||||||
|
//
|
||||||
|
// 1. The Bearer token is a valid JWT issued by the configured Dex OIDC server, or
|
||||||
|
// 2. The Bearer token matches staticToken (constant-time compare).
|
||||||
|
//
|
||||||
|
// Any other case — including missing or empty Authorization header — returns 401.
|
||||||
|
//
|
||||||
|
// The Gitea service PAT is intentionally NOT used to authenticate the caller:
|
||||||
|
// it is only used by the Gitea client for upstream API calls. Decoupling the
|
||||||
|
// two prevents the MCP endpoint from being reachable anonymously when a service
|
||||||
|
// PAT happens to be configured.
|
||||||
|
func BearerMiddleware(jwtValidator *JWTValidator, staticToken string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !hasBearer || bearer == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if jwtValidator.Validate(r.Context(), bearer) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if staticToken != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(staticToken)) == 1 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
92
internal/auth/bearer_test.go
Normal file
92
internal/auth/bearer_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func okHandler(called *bool) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if called != nil {
|
||||||
|
*called = true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_NoAuthHeader(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_NoAuthHeader_RejectsEvenWhenStaticConfigured(t *testing.T) {
|
||||||
|
// A configured staticToken must not allow unauthenticated callers through.
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "any-static", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_EmptyBearer(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "static", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer ")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_StaticToken_Valid(t *testing.T) {
|
||||||
|
const staticToken = "my-static-token"
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, staticToken, okHandler(&called)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+staticToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.True(t, called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_StaticToken_Invalid(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "correct-token", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerMiddleware_UnknownBearer_NoStatic_NoJWT(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer random-unknown-token")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
79
internal/auth/jwt.go
Normal file
79
internal/auth/jwt.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTValidator validates bearer tokens as JWTs issued by a Dex OIDC server.
|
||||||
|
// A nil JWTValidator always returns false — JWT validation is disabled.
|
||||||
|
type JWTValidator struct {
|
||||||
|
issuer string
|
||||||
|
aud string
|
||||||
|
cache *jwk.Cache
|
||||||
|
jwksURI string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWTValidator creates a validator by fetching the OIDC discovery document
|
||||||
|
// from issuerURL. Returns nil, nil when issuerURL is empty (disabled).
|
||||||
|
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
|
||||||
|
if issuerURL == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var doc struct {
|
||||||
|
JWKSURI string `json:"jwks_uri"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := jwk.NewCache(ctx)
|
||||||
|
if err := cache.Register(doc.JWKSURI, jwk.WithRefreshInterval(time.Hour)); err != nil {
|
||||||
|
return nil, fmt.Errorf("register jwks uri: %w", err)
|
||||||
|
}
|
||||||
|
// warm the cache immediately so first request doesn't block
|
||||||
|
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
||||||
|
return nil, fmt.Errorf("warm jwks cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JWTValidator{
|
||||||
|
issuer: issuerURL,
|
||||||
|
aud: audience,
|
||||||
|
cache: cache,
|
||||||
|
jwksURI: doc.JWKSURI,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate returns true if rawToken is a valid JWT signed by the OIDC server.
|
||||||
|
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) bool {
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
opts := []jwt.ParseOption{
|
||||||
|
jwt.WithKeySet(keySet),
|
||||||
|
jwt.WithIssuer(v.issuer),
|
||||||
|
jwt.WithValidate(true),
|
||||||
|
}
|
||||||
|
if v.aud != "" {
|
||||||
|
opts = append(opts, jwt.WithAudience(v.aud))
|
||||||
|
}
|
||||||
|
_, err = jwt.Parse([]byte(rawToken), opts...)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -8,18 +8,26 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Port string // GITEA_MCP_PORT, default 8080
|
Port string // GITEA_MCP_PORT, default 8080
|
||||||
GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be
|
GiteaBaseURL string // GITEA_BASE_URL, e.g. https://gitea.d-ma.be
|
||||||
GiteaAPIToken string // GITEA_API_TOKEN — bot user token
|
DefaultToken string // GITEA_MCP_DEFAULT_TOKEN, service PAT; used by Gitea client for all upstream calls
|
||||||
|
StaticToken string // GITEA_MCP_STATIC_TOKEN, optional static bearer for service-to-service auth
|
||||||
AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias"
|
AllowedOwners []string // GITEA_MCP_ALLOWED_OWNERS, comma-separated, default "mathias"
|
||||||
OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated
|
OriginAllowlist []string // GITEA_MCP_ORIGIN_ALLOWLIST, comma-separated
|
||||||
|
DexIssuerURL string // DEX_ISSUER_URL, e.g. https://auth.d-ma.be; empty disables JWT auth
|
||||||
|
MCPAudience string // MCP_AUDIENCE, JWT audience claim to validate, e.g. claude-ai
|
||||||
|
MCPResourceURL string // MCP_RESOURCE_URL, this server's public URL for /.well-known metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Port: envOr("GITEA_MCP_PORT", "8080"),
|
Port: envOr("GITEA_MCP_PORT", "8080"),
|
||||||
GiteaBaseURL: os.Getenv("GITEA_BASE_URL"),
|
GiteaBaseURL: os.Getenv("GITEA_BASE_URL"),
|
||||||
GiteaAPIToken: os.Getenv("GITEA_API_TOKEN"),
|
DefaultToken: os.Getenv("GITEA_MCP_DEFAULT_TOKEN"),
|
||||||
|
StaticToken: os.Getenv("GITEA_MCP_STATIC_TOKEN"),
|
||||||
AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")),
|
AllowedOwners: splitCSV(envOr("GITEA_MCP_ALLOWED_OWNERS", "mathias")),
|
||||||
OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")),
|
OriginAllowlist: splitCSV(os.Getenv("GITEA_MCP_ORIGIN_ALLOWLIST")),
|
||||||
|
DexIssuerURL: os.Getenv("DEX_ISSUER_URL"),
|
||||||
|
MCPAudience: os.Getenv("MCP_AUDIENCE"),
|
||||||
|
MCPResourceURL: os.Getenv("MCP_RESOURCE_URL"),
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
func TestLoadDefaults(t *testing.T) {
|
func TestLoadDefaults(t *testing.T) {
|
||||||
t.Setenv("GITEA_BASE_URL", "")
|
t.Setenv("GITEA_BASE_URL", "")
|
||||||
t.Setenv("GITEA_API_TOKEN", "")
|
|
||||||
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "")
|
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "")
|
||||||
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "")
|
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "")
|
||||||
t.Setenv("GITEA_MCP_PORT", "")
|
t.Setenv("GITEA_MCP_PORT", "")
|
||||||
@@ -23,7 +22,6 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadFromEnv(t *testing.T) {
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
t.Setenv("GITEA_BASE_URL", "https://gitea.d-ma.be")
|
t.Setenv("GITEA_BASE_URL", "https://gitea.d-ma.be")
|
||||||
t.Setenv("GITEA_API_TOKEN", "test-token")
|
|
||||||
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "mathias,acme")
|
t.Setenv("GITEA_MCP_ALLOWED_OWNERS", "mathias,acme")
|
||||||
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com")
|
t.Setenv("GITEA_MCP_ORIGIN_ALLOWLIST", "https://claude.ai,https://api.anthropic.com")
|
||||||
t.Setenv("GITEA_MCP_PORT", "9000")
|
t.Setenv("GITEA_MCP_PORT", "9000")
|
||||||
@@ -31,7 +29,6 @@ func TestLoadFromEnv(t *testing.T) {
|
|||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://gitea.d-ma.be", cfg.GiteaBaseURL)
|
assert.Equal(t, "https://gitea.d-ma.be", cfg.GiteaBaseURL)
|
||||||
assert.Equal(t, "test-token", cfg.GiteaAPIToken)
|
|
||||||
assert.Equal(t, []string{"mathias", "acme"}, cfg.AllowedOwners)
|
assert.Equal(t, []string{"mathias", "acme"}, cfg.AllowedOwners)
|
||||||
assert.Equal(t, []string{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist)
|
assert.Equal(t, []string{"https://claude.ai", "https://api.anthropic.com"}, cfg.OriginAllowlist)
|
||||||
assert.Equal(t, "9000", cfg.Port)
|
assert.Equal(t, "9000", cfg.Port)
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if c.token != "" {
|
token := c.token
|
||||||
req.Header.Set("Authorization", "token "+c.token)
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -114,8 +115,9 @@ func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if c.token != "" {
|
token := c.token
|
||||||
req.Header.Set("Authorization", "token "+c.token)
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -86,3 +86,34 @@ func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error)
|
|||||||
}
|
}
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditRepoArgs carries optional fields for PATCH /api/v1/repos/{owner}/{name}.
|
||||||
|
// Pointer fields let the caller omit unset values from the wire payload, so the
|
||||||
|
// server only patches what was explicitly requested.
|
||||||
|
type EditRepoArgs struct {
|
||||||
|
Archived *bool `json:"archived,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Private *bool `json:"private,omitempty"`
|
||||||
|
Website *string `json:"website,omitempty"`
|
||||||
|
Template *bool `json:"template,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) EditRepo(ctx context.Context, owner, name string, args EditRepoArgs) (*Repo, error) {
|
||||||
|
body, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal edit args: %w", err)
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
|
||||||
|
resp, status, err := c.PatchJSON(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := MapStatus(status, resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r Repo
|
||||||
|
if err := json.Unmarshal(resp, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProtocolVersion = "2025-06-18"
|
ProtocolVersion = "2025-03-26"
|
||||||
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
maxRequestBodyBytes = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +56,6 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialize is the only method allowed without a session.
|
|
||||||
if req.Method == "initialize" {
|
if req.Method == "initialize" {
|
||||||
sid := s.opts.Sessions.Issue()
|
sid := s.opts.Sessions.Issue()
|
||||||
w.Header().Set("Mcp-Session-Id", sid)
|
w.Header().Set("Mcp-Session-Id", sid)
|
||||||
@@ -68,11 +67,12 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sid := r.Header.Get("Mcp-Session-Id")
|
// Mcp-Session-Id is advisory: we issue one on initialize and accept it back,
|
||||||
if !s.opts.Sessions.Valid(sid) {
|
// but every tool the gitea-mcp server exposes is stateless single-shot, so
|
||||||
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest)
|
// we do not gate non-initialize calls on it. The claude.ai connector's
|
||||||
return
|
// transport proxy is observed to not propagate the session header reliably,
|
||||||
}
|
// and the spec allows servers to be sessionless. Compare with brain-mcp /
|
||||||
|
// supervisor-mcp, which never required a session at all.
|
||||||
|
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "tools/list":
|
case "tools/list":
|
||||||
@@ -111,11 +111,8 @@ func (s *Server) handlePOST(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGET(w http.ResponseWriter, r *http.Request) {
|
||||||
sid := r.Header.Get("Mcp-Session-Id")
|
// Session ID is optional for GET: clients may open the SSE stream before
|
||||||
if !s.opts.Sessions.Valid(sid) {
|
// calling initialize (e.g. claude.ai probes on add). Accept with or without.
|
||||||
http.Error(w, "missing or invalid Mcp-Session-Id", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|||||||
@@ -52,19 +52,27 @@ func TestInitialize(t *testing.T) {
|
|||||||
var resp map[string]any
|
var resp map[string]any
|
||||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
result := resp["result"].(map[string]any)
|
result := resp["result"].(map[string]any)
|
||||||
assert.Equal(t, "2025-06-18", result["protocolVersion"])
|
assert.Equal(t, mcp.ProtocolVersion, result["protocolVersion"])
|
||||||
si := result["serverInfo"].(map[string]any)
|
si := result["serverInfo"].(map[string]any)
|
||||||
assert.Equal(t, "gitea-mcp", si["name"])
|
assert.Equal(t, "gitea-mcp", si["name"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPostWithoutSessionRejected(t *testing.T) {
|
func TestPostWithoutSessionAccepted(t *testing.T) {
|
||||||
|
// gitea-mcp tools are stateless single-shot; Mcp-Session-Id is advisory.
|
||||||
|
// claude.ai's MCP transport proxy is observed to not propagate the
|
||||||
|
// session header reliably, so non-initialize calls must work without it.
|
||||||
srv := newServer(t)
|
srv := newServer(t)
|
||||||
rr := postJSON(t, srv, map[string]any{
|
rr := postJSON(t, srv, map[string]any{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"method": "tools/list",
|
"method": "tools/list",
|
||||||
}, "")
|
}, "")
|
||||||
require.Equal(t, http.StatusBadRequest, rr.Code)
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
result := resp["result"].(map[string]any)
|
||||||
|
assert.Contains(t, result, "tools")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerWithOriginAllowlistRejectsBadOrigin(t *testing.T) {
|
func TestServerWithOriginAllowlistRejectsBadOrigin(t *testing.T) {
|
||||||
|
|||||||
@@ -45,14 +45,15 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO
|
|||||||
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "create_project_from_template",
|
Name: "create_project_from_template",
|
||||||
Description: "Create a new project repo from the template, applying placeholder substitutions to known files.",
|
Description: "Create a new project repo from a template, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).",
|
||||||
InputSchema: json.RawMessage(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
"owner":{"type":"string"},
|
"owner":{"type":"string"},
|
||||||
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||||
"description":{"type":"string"},
|
"description":{"type":"string"},
|
||||||
"private":{"type":"boolean"}
|
"private":{"type":"boolean"},
|
||||||
|
"template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."}
|
||||||
},
|
},
|
||||||
"required":["owner","name"]
|
"required":["owner","name"]
|
||||||
}`),
|
}`),
|
||||||
@@ -64,6 +65,7 @@ type createProjectArgs struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
|
TemplateName string `json:"template_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createProjectResult struct {
|
type createProjectResult struct {
|
||||||
@@ -91,13 +93,20 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve template: per-call override takes precedence over the
|
||||||
|
// server-configured default. Owner stays server-configured.
|
||||||
|
tmplName := args.TemplateName
|
||||||
|
if tmplName == "" {
|
||||||
|
tmplName = t.templateName
|
||||||
|
}
|
||||||
|
|
||||||
// Verify template exists and is marked as a template repo.
|
// Verify template exists and is marked as a template repo.
|
||||||
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("template lookup: %w", err)
|
return nil, fmt.Errorf("template lookup: %w", err)
|
||||||
}
|
}
|
||||||
if !tmpl.Template {
|
if !tmpl.Template {
|
||||||
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, gitea.ErrValidation)
|
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, tmplName, gitea.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify destination doesn't already exist.
|
// Verify destination doesn't already exist.
|
||||||
@@ -108,7 +117,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate repo from template.
|
// Generate repo from template.
|
||||||
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
|
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, tmplName, gitea.GenerateFromTemplateArgs{
|
||||||
Owner: args.Owner,
|
Owner: args.Owner,
|
||||||
Name: args.Name,
|
Name: args.Name,
|
||||||
Description: args.Description,
|
Description: args.Description,
|
||||||
|
|||||||
@@ -122,6 +122,62 @@ func TestCreateProjectHappyPath(t *testing.T) {
|
|||||||
assert.Empty(t, out.PartialFailure)
|
assert.Empty(t, out.PartialFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the
|
||||||
|
// server-configured default, so the same binary can generate from template-go-web or
|
||||||
|
// template-go-agent without restart.
|
||||||
|
func TestCreateProjectTemplateNameOverride(t *testing.T) {
|
||||||
|
var templateLookups, generateCalls []string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-agent":
|
||||||
|
templateLookups = append(templateLookups, "template-go-agent")
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
|
||||||
|
templateLookups = append(templateLookups, "template-go-web")
|
||||||
|
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"):
|
||||||
|
generateCalls = append(generateCalls, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent")))
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
||||||
|
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
|
||||||
|
|
||||||
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"):
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
|
||||||
|
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Server is configured with template-go-web as the default; call overrides to template-go-agent.
|
||||||
|
tool := newCreateProjectTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`,
|
||||||
|
))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"template-go-agent"}, templateLookups,
|
||||||
|
"override must direct the template lookup, not the server default")
|
||||||
|
require.Len(t, generateCalls, 1)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0],
|
||||||
|
"override must direct the /generate call too")
|
||||||
|
}
|
||||||
|
|
||||||
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
|
||||||
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
func TestCreateProjectNameRegexFailure(t *testing.T) {
|
||||||
tool := tools.NewCreateProjectFromTemplate(
|
tool := tools.NewCreateProjectFromTemplate(
|
||||||
|
|||||||
@@ -143,7 +143,13 @@ func splitUnifiedDiff(d []byte) map[string][]byte {
|
|||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
if currentFile != "" {
|
if currentFile != "" {
|
||||||
m[currentFile] = current.Bytes()
|
// Copy: bytes.Buffer.Bytes() returns the internal slice,
|
||||||
|
// which Reset() then reuses. Without the copy, every map
|
||||||
|
// entry ends up aliased to the last file's data.
|
||||||
|
b := current.Bytes()
|
||||||
|
cp := make([]byte, len(b))
|
||||||
|
copy(cp, b)
|
||||||
|
m[currentFile] = cp
|
||||||
current.Reset()
|
current.Reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,47 @@ func TestPRFilesDiffSmall(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, fileNames, paths)
|
assert.ElementsMatch(t, fileNames, paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression for issue #25: every file's diff entry must contain its OWN diff,
|
||||||
|
// not a shared buffer pointing at the last file. Prior bug: splitUnifiedDiff
|
||||||
|
// flushed bytes.Buffer.Bytes() into the map without copying, so every entry
|
||||||
|
// aliased the buffer's backing array and showed the last file's content.
|
||||||
|
func TestPRFilesDiffPerFileIsolation(t *testing.T) {
|
||||||
|
fileNames := []string{"alpha.go", "beta.go", "gamma.go", "delta.go"}
|
||||||
|
rawDiff := buildDiff(fileNames, 5)
|
||||||
|
filesJSON := buildFilesJSON(fileNames, 5)
|
||||||
|
|
||||||
|
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Files []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Diff string `json:"diff"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(result, &out))
|
||||||
|
require.Len(t, out.Files, len(fileNames))
|
||||||
|
|
||||||
|
for _, f := range out.Files {
|
||||||
|
expected := fmt.Sprintf("diff --git a/%s b/%s", f.Path, f.Path)
|
||||||
|
assert.Contains(t, f.Diff, expected,
|
||||||
|
"file %s diff must contain its own header, got: %.80q", f.Path, f.Diff)
|
||||||
|
// No other file's header should leak in.
|
||||||
|
for _, other := range fileNames {
|
||||||
|
if other == f.Path {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
otherHeader := fmt.Sprintf("diff --git a/%s b/%s", other, other)
|
||||||
|
assert.NotContains(t, f.Diff, otherHeader,
|
||||||
|
"file %s diff must NOT contain %s's header", f.Path, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
|
||||||
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
|
||||||
fileNames := []string{"bigfile.go"}
|
fileNames := []string{"bigfile.go"}
|
||||||
|
|||||||
91
internal/tools/repo_update.go
Normal file
91
internal/tools/repo_update.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoUpdate struct {
|
||||||
|
c *gitea.Client
|
||||||
|
a *allowlist.Allowlist
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoUpdate {
|
||||||
|
return &RepoUpdate{c: c, a: a}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoUpdate) Descriptor() registry.ToolDescriptor {
|
||||||
|
return registry.ToolDescriptor{
|
||||||
|
Name: "repo_update",
|
||||||
|
Description: "Update repository metadata (description, visibility, default branch, website, archived, template). " +
|
||||||
|
"Only fields explicitly set in the call are patched. " +
|
||||||
|
"WARNING: private=false exposes the repo publicly — verify intent before calling.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"owner":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"description":{"type":"string"},
|
||||||
|
"private":{"type":"boolean","description":"Toggle visibility. false makes the repo public."},
|
||||||
|
"website":{"type":"string","description":"Homepage URL"},
|
||||||
|
"default_branch":{"type":"string","description":"Rename the default branch"},
|
||||||
|
"archived":{"type":"boolean","description":"Mark repo as archived (read-only)."},
|
||||||
|
"template":{"type":"boolean","description":"Toggle template-repo flag"},
|
||||||
|
"confirm":{"type":"string","description":"Required when setting private=false. Must equal the repo name."}
|
||||||
|
},
|
||||||
|
"required":["owner","name"]
|
||||||
|
}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoUpdateArgs struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Private *bool `json:"private,omitempty"`
|
||||||
|
Website *string `json:"website,omitempty"`
|
||||||
|
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||||
|
Archived *bool `json:"archived,omitempty"`
|
||||||
|
Template *bool `json:"template,omitempty"`
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *RepoUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var args repoUpdateArgs
|
||||||
|
if err := parseArgs(raw, &args); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := t.a.Check(args.Owner); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Making a repo public is a significant action — require explicit confirmation.
|
||||||
|
if args.Private != nil && !*args.Private {
|
||||||
|
if args.Confirm != args.Name {
|
||||||
|
return nil, fmt.Errorf("setting private=false makes the repo public: set confirm=%q to proceed", args.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Description == nil && args.Private == nil && args.Website == nil &&
|
||||||
|
args.DefaultBranch == nil && args.Archived == nil && args.Template == nil {
|
||||||
|
return nil, fmt.Errorf("at least one updatable field must be set: %w", gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := t.c.UpdateRepo(ctx, args.Owner, args.Name, gitea.UpdateRepoArgs{
|
||||||
|
Description: args.Description,
|
||||||
|
Private: args.Private,
|
||||||
|
Website: args.Website,
|
||||||
|
DefaultBranch: args.DefaultBranch,
|
||||||
|
Archived: args.Archived,
|
||||||
|
Template: args.Template,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return textOK(r)
|
||||||
|
}
|
||||||
139
internal/tools/repo_update_test.go
Normal file
139
internal/tools/repo_update_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package tools_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||||
|
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRepoUpdateTool(srvURL string) *tools.RepoUpdate {
|
||||||
|
return tools.NewRepoUpdate(gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepoUpdateArchive: happy path — set archived=true.
|
||||||
|
func TestRepoUpdateArchive(t *testing.T) {
|
||||||
|
var patchedBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPatch, r.Method)
|
||||||
|
require.Equal(t, "/api/v1/repos/mathias/old-svc", r.URL.Path)
|
||||||
|
patchedBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"old-svc","full_name":"mathias/old-svc","default_branch":"main","template":false,"private":false}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newRepoUpdateTool(srv.URL)
|
||||||
|
result, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"mathias","name":"old-svc","archived":true}`,
|
||||||
|
))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wire payload only contains the field that was actually set.
|
||||||
|
var sent map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||||
|
assert.Equal(t, true, sent["archived"])
|
||||||
|
assert.NotContains(t, sent, "description")
|
||||||
|
assert.NotContains(t, sent, "private")
|
||||||
|
assert.NotContains(t, sent, "website")
|
||||||
|
assert.NotContains(t, sent, "template")
|
||||||
|
|
||||||
|
var repo gitea.Repo
|
||||||
|
require.NoError(t, json.Unmarshal(result, &repo))
|
||||||
|
assert.Equal(t, "mathias/old-svc", repo.FullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepoUpdateMultipleFields: set description + template flag in one call.
|
||||||
|
func TestRepoUpdateMultipleFields(t *testing.T) {
|
||||||
|
var patchedBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
patchedBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"template-go-agent","full_name":"mathias/template-go-agent","description":"Go agent template","template":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newRepoUpdateTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"mathias","name":"template-go-agent","description":"Go agent template","template":true}`,
|
||||||
|
))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var sent map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||||
|
assert.Equal(t, "Go agent template", sent["description"])
|
||||||
|
assert.Equal(t, true, sent["template"])
|
||||||
|
assert.NotContains(t, sent, "archived")
|
||||||
|
assert.NotContains(t, sent, "private")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepoUpdateNoFieldsRejected: zero updatable fields → validation error before network.
|
||||||
|
func TestRepoUpdateNoFieldsRejected(t *testing.T) {
|
||||||
|
tool := tools.NewRepoUpdate(
|
||||||
|
gitea.NewClient("http://unused", ""),
|
||||||
|
allowlist.New([]string{"mathias"}),
|
||||||
|
)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"mathias","name":"some-repo"}`,
|
||||||
|
))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepoUpdateMakePublic: explicit private=false is allowed; wire payload carries the false.
|
||||||
|
// (The destructive nature is warned about in the tool description, not blocked by the tool.)
|
||||||
|
func TestRepoUpdateMakePublic(t *testing.T) {
|
||||||
|
var patchedBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
patchedBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"name":"open-repo","full_name":"mathias/open-repo","private":false}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newRepoUpdateTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"mathias","name":"open-repo","private":false}`,
|
||||||
|
))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var sent map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(patchedBody, &sent))
|
||||||
|
assert.Equal(t, false, sent["private"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepoUpdateAllowlistRejects: owner outside allowlist denied without network call.
|
||||||
|
func TestRepoUpdateAllowlistRejects(t *testing.T) {
|
||||||
|
tool := tools.NewRepoUpdate(
|
||||||
|
gitea.NewClient("http://unused", ""),
|
||||||
|
allowlist.New([]string{"mathias"}),
|
||||||
|
)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"evil","name":"some-repo","archived":true}`,
|
||||||
|
))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepoUpdateUpstreamError: server 500 propagates as ErrUpstream.
|
||||||
|
func TestRepoUpdateUpstreamError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"internal"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tool := newRepoUpdateTool(srv.URL)
|
||||||
|
_, err := tool.Call(context.Background(), json.RawMessage(
|
||||||
|
`{"owner":"mathias","name":"some-repo","archived":true}`,
|
||||||
|
))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, gitea.ErrUpstream)
|
||||||
|
}
|
||||||
201
scripts/context-sync.sh
Executable file
201
scripts/context-sync.sh
Executable file
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generates harness-specific context files from .context/PROJECT.md
|
||||||
|
# Project-level script — run from a project directory.
|
||||||
|
#
|
||||||
|
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
||||||
|
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
|
||||||
|
# Task: task context:sync
|
||||||
|
#
|
||||||
|
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Parse --force flag and collect adapter names separately
|
||||||
|
FORCE=false
|
||||||
|
ADAPTERS=()
|
||||||
|
for _arg in "$@"; do
|
||||||
|
case "$_arg" in
|
||||||
|
--force) FORCE=true ;;
|
||||||
|
*) ADAPTERS+=("$_arg") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
PROJECT_FILE=".context/PROJECT.md"
|
||||||
|
|
||||||
|
# Walk up to find root .context/AGENT.md
|
||||||
|
find_root_context() {
|
||||||
|
local dir
|
||||||
|
dir="$(pwd)"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
if [ -f "$dir/.context/AGENT.md" ]; then
|
||||||
|
echo "$dir/.context/AGENT.md"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ROOT_CONTEXT="${ROOT_CONTEXT:-$(find_root_context)}"
|
||||||
|
|
||||||
|
if [ ! -f "$PROJECT_FILE" ]; then
|
||||||
|
echo "Error: $PROJECT_FILE not found. Are you in a project root?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pre-flight: reject unfilled {{...}} placeholders unless --force
|
||||||
|
if [ "$FORCE" = false ]; then
|
||||||
|
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$_placeholders" ]; then
|
||||||
|
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
|
||||||
|
while IFS= read -r _match; do
|
||||||
|
_lineno="${_match%%:*}"
|
||||||
|
_content="${_match#*:}"
|
||||||
|
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||||
|
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||||
|
done <<< "$_placeholders"
|
||||||
|
echo "" >&2
|
||||||
|
echo "Fill these placeholders, then re-run: task context:sync" >&2
|
||||||
|
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
|
echo " Root context: $ROOT_CONTEXT"
|
||||||
|
else
|
||||||
|
echo " No root AGENT.md found (project context only)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Emit root context + separator
|
||||||
|
root_block() {
|
||||||
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
|
cat "$ROOT_CONTEXT"
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Claude Code ──────────────────────────────────────────────
|
||||||
|
# Claude Code walks up the tree — it finds ~/dev/CLAUDE.md automatically.
|
||||||
|
# Project-level CLAUDE.md only needs project-specific context.
|
||||||
|
generate_claude() {
|
||||||
|
cat "$PROJECT_FILE" > CLAUDE.md
|
||||||
|
echo " → CLAUDE.md (project-only; Claude Code inherits root)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── AGENTS.md (Crush, Pi, Antigravity) ──────────────────────
|
||||||
|
# These tools read AGENTS.md from cwd but don't walk up.
|
||||||
|
# Concatenate root + project.
|
||||||
|
generate_agents() {
|
||||||
|
{ root_block; cat "$PROJECT_FILE"; } > AGENTS.md
|
||||||
|
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() {
|
||||||
|
{
|
||||||
|
echo "You are a coding assistant working on a specific project."
|
||||||
|
echo "Follow all conventions from both the root agent context and project context."
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo ""
|
||||||
|
root_block
|
||||||
|
cat "$PROJECT_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
} > .context/system-prompt.txt
|
||||||
|
echo " → .context/system-prompt.txt (root + project)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── MCP config ───────────────────────────────────────────────
|
||||||
|
generate_mcp() {
|
||||||
|
# Ensure baseline file exists with project-specific knowledge server
|
||||||
|
if [ ! -f .context/mcp.json ]; then
|
||||||
|
cat > .context/mcp.json << 'JSON'
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"knowledge": {
|
||||||
|
"url": "http://localhost:3100/mcp",
|
||||||
|
"description": "Project knowledge base — vector + graph retrieval"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge root mcp-servers.json if found alongside root AGENT.md
|
||||||
|
local root_mcp=""
|
||||||
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
|
local candidate
|
||||||
|
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
|
||||||
|
[ -f "$candidate" ] && root_mcp="$candidate"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$root_mcp" ]; then
|
||||||
|
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Root servers take precedence over project entries on key conflict
|
||||||
|
local root_servers count updated
|
||||||
|
root_servers=$(jq '.servers' "$root_mcp")
|
||||||
|
count=$(printf '%s' "$root_servers" | jq 'keys | length')
|
||||||
|
updated=$(jq --argjson root "$root_servers" \
|
||||||
|
'.mcpServers = (.mcpServers + $root)' \
|
||||||
|
.context/mcp.json)
|
||||||
|
printf '%s\n' "$updated" > .context/mcp.json
|
||||||
|
echo " → .context/mcp.json (merged $count root servers)"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
for adapter in "${ADAPTERS[@]}"; do
|
||||||
|
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 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
Reference in New Issue
Block a user