Compare commits

..

10 Commits

Author SHA1 Message Date
TBNilles c6d3b5fae8 Slice 7: expose git commands as MCP tools
Add git_fetch/git_pull/git_push/git_commit/git_discard_changes MCP tools as thin adapters over the service (actor=claude), completing 1.7 symmetry so Claude can run the same commands as the right-click menu. git_discard_changes is flagged destructive (confirm first, 1.4). Extended the MCP test with a git_commit round-trip; synced AGENT.md 8.1 tool list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 09:15:23 -04:00
TBNilles 6ef1c195e3 Slice 6: right-click command menu + git write actions
git boundary: Pull/Push/Commit/DiscardAll (discard is 1.4-destructive). Scanner.RefreshRepo re-scans one repo after a mutation. Service GitFetch/GitPull/GitPush/GitCommit/GitDiscard record a git-* activity event and refresh on success; service.New takes a refresh hook. HTTP POST /api/repo/git. New <repo-menu> overlay with plain-language commands (Get latest/Publish/Check for updates/Save my work/Set active/Ask Claude to switch/Copy path/Discard all changes), summoned by repo-list's repo:contextmenu. Added service test for commit/discard on a temp repo. Verified the menu live for safe commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 09:12:18 -04:00
TBNilles 9d1519222c Slice 5: Gitea forge - PRs and Merge & clean up
New internal/forge (provider-abstracted, Gitea impl via code.gitea.io/sdk/gitea) with tested remote-URL parsing and read+write ops: list open PRs, and merge-and-cleanup (squash-merge + delete head branch when head/base share a repo). Service resolves repo->owner/repo from remotes (prefers origin) and records a pr-merged event; config gains GITEA_URL/GITEA_TOKEN (forge disabled without both). MCP tools list_prs and merge_and_cleanup_pr (merge tool tells Claude to confirm first, 1.4). HTTP GET /api/repo/prs, POST /api/repo/pr/merge. New <pr-list> component with a confirming Merge & clean up button, hidden when no forge. Verified graceful-disabled path; real merge pending token + a designated PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:37:51 -04:00
TBNilles 2b77e15b36 Slice 4: graceful project handoff (pending switch + ack)
Add pending-switch coordination to internal/activity (RequestSwitch/PendingSwitch/AckSwitch/CancelSwitch); AckSwitch atomically sets the active project and records switch-completed with Claude's summary. New MCP tools get_pending_switch and ack_switch (request is user-only via HTTP). HTTP GET/POST/DELETE /api/switch. New <handoff-bar> component (ask/waiting/cancel/completed) over SSE; activity-feed reflects switch-completed. Test covers request->ack->clear. Verified live: request/waiting/cancel over SSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:27:22 -04:00
TBNilles 65daedc902 Slice 3: activity feed and active project (SSE + MCP tools)
Add internal/activity (thread-safe active project + bounded event feed with subscriber fan-out). Service exposes active-project/activity methods and takes the feed. New MCP tools get_active_project/set_active_project/get_activity and HTTP endpoints GET/POST /api/active-project, /api/activity, and /events (SSE). New <activity-feed> component updates live via EventSource; <repo-list> sets the active project on selection. Verified end-to-end in the running app: user actions push live events and set the active project (actor=user), all readable by Claude over MCP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:16:42 -04:00
TBNilles d3abd4416e Fix MCP tools/list rejection: wrap list_repos slice in object
The go-sdk infers each tool's outputSchema from its handler result
type, and MCP structured output must be type "object". list_repos
returned []repos.State, yielding outputSchema.type "array", which
Claude Desktop rejects at tools/list — taking the whole server down.

Wrap the slice in listReposOutput{Repos: ...} so the schema is an
object, update the round-trip test, and record the struct-result rule
in AGENT.md §8.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:00:30 -04:00
TBNilles e3336efc25 Correct Claude Desktop connection: local stdio bridge, not GUI connector
The GUI custom-connector flow validates/calls from Anthropic's cloud and can't reach a localhost server, so a 127.0.0.1 URL fails there. The working path is a local mcp-remote stdio bridge configured in claude_desktop_config.json pointing at http://127.0.0.1:8080/mcp. Updated AGENT.md 8.1/11 and CHANGELOG. HTTPS on :8443 stays available but optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 07:52:31 -04:00
TBNilles f23f2f2b30 Serve MCP over HTTPS for the Claude Desktop connector
Claude Desktop's custom connector only accepts https URLs. Added an optional TLS listener (HTTPS_ADDR + TLS_CERT_FILE/TLS_KEY_FILE) alongside HTTP; docker-compose publishes 127.0.0.1:8443 and mounts a local mkcert cert from certs/ (git-ignored). Best-effort: a missing cert logs a warning and stays HTTP-only. Verified the Windows store trusts the mkcert cert and MCP initialize succeeds over https://127.0.0.1:8443/mcp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 07:40:21 -04:00
TBNilles 2d7f814a23 Add service layer and MCP server with read tools
Slice 1: internal/service is the one capability layer both the HTTP API and the MCP server call (AGENT.md 1.7); /api/repos and /api/repo route through it. Slice 2: internal/mcp serves an MCP server over Streamable HTTP at /mcp (go-sdk v1.8.0) with read tools list_repos and get_repo as thin adapters over the service, plus a round-trip test. Enabled air polling so hot reload works across the Docker-on-Windows bind mount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:18:40 -04:00
TBNilles dfa45de40c Redefine contract: two-way Claude companion (MCP + handoff + forge writes)
AGENT.md now makes the Claude integration the defining pillar: law 1.7 (one service layer behind GUI + MCP), MCP server over Streamable HTTP at /mcp, SSE app->browser, Gitea-first read+write forge, right-click command vocabulary, and rewritten section 8 (MCP server, activity feed, graceful project handoff, merge & clean up). No code yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:11:51 -04:00
32 changed files with 2854 additions and 45 deletions
+5
View File
@@ -12,6 +12,11 @@ tmp_dir = "tmp"
exclude_dir = ["tmp", "bin", ".git", "repos"]
delay = 500
stop_on_error = true
# Poll for changes instead of relying on fsnotify: filesystem events do NOT
# cross the Windows host -> Linux container bind mount, so watch-based reload
# silently never fires. Polling is the reliable option in Docker on Windows.
poll = true
poll_interval = 500
[log]
time = true
+24 -2
View File
@@ -7,6 +7,20 @@
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately.
LISTEN_ADDR=127.0.0.1:8080
# HTTPS (optional, but REQUIRED for the MCP connector — Claude Desktop only
# accepts https:// URLs). When HTTPS_ADDR and both cert/key are set, an HTTPS
# listener starts alongside HTTP. docker-compose sets these to the mounted certs.
# Generate a locally-trusted cert on the HOST with mkcert (installs a local CA
# your OS — and Claude Desktop — will trust), from the project root:
# winget install FiloSottile.mkcert
# mkcert -install # trust step (adds the local CA)
# mkdir certs
# mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
# Then connect Claude Desktop to https://localhost:8443/mcp
HTTPS_ADDR=
TLS_CERT_FILE=
TLS_KEY_FILE=
# Roots to scan for Git repositories, comma-separated (absolute paths).
# Inside Docker these must be the *container* paths that the host roots are
# mounted to (see docker-compose.yml). Example: /repos,/work/other
@@ -44,7 +58,15 @@ APP_ENV=dev
# Optional: also append structured logs to this file. Leave empty to disable.
LOG_FILE=
# --- Forge integration — OPTIONAL, read-only, token-gated (AGENT.md §8) -----
# With no token the feature is simply absent; the rest of the app is unaffected.
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
# token to enable PRs + "Merge & clean up"; with neither, the forge features are
# simply absent and the rest of the app is unaffected. A repo is forge-enabled
# when its origin remote host matches GITEA_URL's host. Writes (merge PR + delete
# branch) are confirmed per AGENT.md §1.4. Token scope: repo read + PR write +
# branch delete.
GITEA_URL=
GITEA_TOKEN=
# Later providers, behind the same interface (unused for now):
GITHUB_TOKEN=
GITLAB_TOKEN=
+3
View File
@@ -10,6 +10,9 @@ gitmanager.exe
# Logs
*.log
# Local TLS certificates (generated with mkcert; never commit)
/certs/
# Editor / OS
.DS_Store
.idea/
+175 -27
View File
@@ -22,6 +22,23 @@ dockable UI panels and is architected so that host/forge integrations
(GitHub/GitLab pull and mergerequests) can be attached without touching the
core.
**The larger purpose — a twoway companion to Claude.** The dashboard is the
visible half. The app is also built to work *with* Claude (used from Claude
Desktop / Claude Code) in both directions:
- **Claude → app:** the app is an **MCP server** Claude can drive — list/switch
repos, run Git operations, and create/merge/cleanup pull requests — so Claude
can manage repositories on the user's behalf.
- **App → Claude:** when the user does something in the app (switches project,
merges a PR), the app makes that known so Claude stays in sync. The headline
case is a **graceful project handoff**: the user switches project in the app,
Claude finishes to a safe stopping point, switches, and the user is notified
(Section 8).
The intended user is a developer who does **not** want to memorize Git commands:
the GUI offers plainlanguage, rightclick commands (Section 6), and Claude can
run the same operations through the MCP surface. This is why the app exists — a
plain dashboard is only step one.
This is a **dev environment first**. It is expected to grow continuously via
incremental requests ("add X functionality"). Every addition must keep the
selfdocumenting workflow in Section 9 intact.
@@ -143,6 +160,18 @@ The app must be runnable by a new developer with: clone → copy `.env.example`
`.env` → set the repo roots → `docker compose up`. Document any hostside
prerequisites (SSH agent, credential helper) in `.env.example` and the help page.
### 1.7 One service layer behind both the GUI and the MCP server
The web UI and the MCP server (Section 8) are **two front doors to the same
capabilities** — never two implementations. Every operation (a Git action, a
forge action, switching the active project) lives once in an internal service
that goes through the `internal/git` and `internal/forge` boundaries; the Echo
HTTP handlers and the MCP tool handlers are **thin adapters** that call it. A
capability added for the GUI is therefore available to Claude, and vice versa,
and safety rules (§1.4) are enforced in the shared layer so neither front door
can bypass them. Do not implement a Git/forge operation directly in an HTTP or
MCP handler.
---
## 2. Technology stack (locked unless told otherwise)
@@ -154,7 +183,9 @@ prerequisites (SSH agent, credential helper) in `.env.example` and the help page
| Page rendering | Go serverrendered HTML shell | Server emits the page + declares web components; components fetch their own data. Use `html/template`. |
| Git access | **System `git` via `os/exec`**, wrapped behind an `internal/git` interface | The system binary is authoritative: it honors the user's credential helpers, SSH keys, hooks, and config exactly. PureGo `go-git` may be proposed for cheap readonly queries, but only behind the same interface and only with approval. |
| Repo discovery / index | **Inmemory cache + `github.com/fsnotify/fsnotify`** (optional) | Scan roots for `.git`, hold an index, refresh on interval and/or on filesystem change. |
| Forge integration (PRs/MRs) | **Providerabstracted** (`internal/forge`) — GitHub via `github.com/google/go-github`, GitLab via `gitlab.com/gitlab-org/api/client-go` | Optional, readonly by default, enabled perhost when a token is configured. See Section 8. |
| Forge integration (PRs/MRs) | **Providerabstracted** (`internal/forge`) — **Gitea/Forgejo first** via `code.gitea.io/sdk/gitea`; GitHub (`github.com/google/go-github`) / GitLab later behind the same interface | **Read/write**, tokengated per host. Writes (merge PR, delete branch) are confirmed per §1.4. See Section 8. The primary host is a selfhosted Gitea (`git.nilles.net`). |
| MCP server | **`github.com/modelcontextprotocol/go-sdk`**, served over **Streamable HTTP** at `/mcp` | Claude Desktop connects as a custom connector. Tools are thin adapters over the shared service layer (§1.7). See Section 8. |
| Realtime (app → browser) | **ServerSent Events** (`net/http`, stdlib) | Push activity + handoff notifications and live repo updates to the components; replaces list polling over time. |
| Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. |
| Config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5. |
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). |
@@ -183,7 +214,10 @@ Section 0).
│ ├── config/ # .env loading, typed config struct
│ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops)
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
│ ├── forge/ # OPTIONAL seam: GitHub/GitLab PR/MR + remote metadata (provider-abstracted)
│ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7)
│ ├── forge/ # provider-abstracted PR/MR read+write (Gitea first) — Section 8
│ ├── mcp/ # MCP server: tool handlers (thin adapters over service) — Section 8
│ ├── activity/ # active-project state, activity feed, pending-switch handoff — Section 8
│ ├── logging/ # slog handler → stdout/stderr (+ optional file)
│ └── render/ # html/template page shell rendering
├── components/ # ALL web components live here (Section 1.2)
@@ -266,6 +300,13 @@ selffetching, independent lifecycle, cleanup on disconnect). Expected surface
- A **repo detail panel** for the selected repo: branches, remotes, recent commit
log, stashes, tags.
- **Diff / commit views** rendered from serverproduced unified diffs.
- A **rightclick context menu** on any item (repo, branch, PR, stash) is the
primary way commands are run. This is the app's reason for being (Section 0):
commands read in **plain language** for people who don't memorize Git — e.g.
"Get latest" (pull), "Save my work" (commit), "Publish" (push), "Merge &
clean up" (merge PR + delete branch). Keep a friendlyname → Git/forgeop
vocabulary; the same operations are exposed to Claude as MCP tools (Section 8),
both calling the one service layer (§1.7).
- An **action surface** for Git operations. Safe operations (fetch, pull,
checkout, create branch, stage, commit, push) can proceed on a normal click;
**destructive operations follow Section 1.4** (explicit control + confirmation
@@ -274,8 +315,8 @@ selffetching, independent lifecycle, cleanup on disconnect). Expected surface
`repo:select` event the detail panel listens for) — never shared globals.
Server endpoints return JSON for the components to selffetch; mutating endpoints
route through `internal/git` and trigger an index refresh for the affected repo so
the UI reflects reality without a full rescan.
route through the service layer (§1.7) and trigger an index refresh for the
affected repo so the UI reflects reality without a full rescan.
---
@@ -292,25 +333,110 @@ the UI reflects reality without a full rescan.
---
## 8. Forge integration — OPTIONAL seam (PRs / MRs)
## 8. Claude integration (MCP server · activity feed · graceful handoff · forge)
Viewing pull/merge requests requires talking to a hosting provider, which is
**outside** the "Git is the store" core. Keep it isolated and optional.
This is the app's defining pillar (Section 0): GitManager works *with* Claude in
both directions. Everything here goes through the one service layer (§1.7) and
obeys the safety rules (§1.4).
- Live in `internal/forge` behind a **provider interface** so GitHub, GitLab, and
others can drop in without touching the dashboard or the Git boundary.
- **Readonly by default**: list open PRs/MRs and their CI/check status for a repo
whose remote points at a supported host. Any write action (comment, merge,
approve) is a **separate, explicitlyrequested** capability — do not build write
paths without asking, and route them through Section 1.4 if destructive.
### 8.1 MCP server — Claude drives the app (`internal/mcp`)
- The app serves an **MCP endpoint over Streamable HTTP at `/mcp`** using
`github.com/modelcontextprotocol/go-sdk`. Like the rest of the app it is
**localhostbound and unauthenticated** (Section 0) — do not expose it offhost.
- **How Claude Desktop connects — the local stdio bridge, NOT the GUI connector.**
The "Add custom connector" GUI is for **remote, publiclyreachable** servers:
it probes (and would call tools) **from Anthropic's cloud**, which cannot reach
`127.0.0.1`. So a localhost URL there fails "couldn't reach the server" even
though a local browser reaches it. The working path is a **local stdio bridge**
configured in `claude_desktop_config.json` under `mcpServers`, launched on the
user's machine:
```json
"gitmanager": { "command": "cmd",
"args": ["/c","npx","-y","mcp-remote","http://127.0.0.1:8080/mcp"] }
```
`mcp-remote` runs locally and speaks stdio to Claude Desktop, so it reaches the
local endpoint directly and needs **no public exposure and no HTTPS**. Do **not**
reach for a public tunnel — that would expose an unauthenticated repomanagement
app to the internet.
- **HTTPS is still available** (`:8443`, mkcert cert) for clients that require it,
but is not needed for the stdiobridge path above.
- MCP **tools are thin adapters** over the service layer — no Git/forge logic in
the tool handlers (§1.7). Expected tools (grow as features land):
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
`get_pending_switch`, `list_prs`.
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`,
`git_discard_changes`, `merge_and_cleanup_pr`, `set_active_project`,
`ack_switch`. (More — `git_checkout`, `create_branch`, `create_pr` — as they land.)
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
structured output must be a JSON **object** (`type: "object"`). A handler that
returns `[]T` yields `outputSchema.type: "array"`, which Claude Desktop rejects
at `tools/list` — and one bad tool takes the whole server down. Wrap any
collection result in a named output struct (e.g. `list_repos` returns
`listReposOutput{ Repos []repos.State }`, not `[]repos.State`). Fixed 2026-09-20.
- **Destructive tools carry the §1.4 contract into MCP:** they describe exactly
what they will do and default to the safe variant. The confirmation is the
human's — surfaced through Claude and/or the app UI — not something the tool
silently assumes.
### 8.2 Activity feed & active project (`internal/activity`)
- The app keeps, in memory (and mirrored to the logs — no new datastore, §1.3):
- the **active project** (the single repo/task currently in focus), and
- an **activity feed** of what happened (user *and* Claude actions: repo
switched, committed, PR merged, …).
- Both are **queryable** (`get_active_project`, `get_activity`) so Claude can
**sync on any turn boundary** — the reliable, pullbased foundation. The app
also **pushes** these to the browser over SSE for live UI. This pullfirst
design does not depend on the host letting a connector wake Claude.
### 8.3 Graceful project handoff (the headline flow)
When the user switches project/task in the app, it is a **request**, not an
instant yank. The cooperative protocol:
1. **User** asks (in the app) for Claude to switch to a project →
`POST /api/switch` records a **pendingswitch request** (target + optional
note) and `<handoff-bar>` shows "waiting for Claude to reach a good stopping
point." (The request is a **useronly** action — there is no MCP tool to raise
it; Claude fulfils requests, it doesn't create them.)
2. **Claude** sees the pending request (it checks at its natural turnboundary
checkpoints via `get_pending_switch`). It **finishes to a safe stopping
point and preserves work** — never abandons uncommitted changes to switch;
it completes the inflight step and commits/stashes as appropriate — then
calls **`ack_switch`** with a short summary of where it left the previous
project. `ack_switch` **atomically** makes the requested target the active
project and clears the request.
3. **App** records `switch-completed` and **notifies the user** over SSE
("Claude switched to *ProjectB* — *left at: …*"). The user proceeds.
**Rule:** the switch is Claudecompleted at a checkpoint, not appforced. Losing
or interrupting uncommitted work to satisfy a switch is a §1.4class violation.
> Fully autonomous "Claude starts working the instant you click, with no turn
> from you" is intentionally **not** assumed — it depends on host push support.
> Build 8.28.3 pullfirst; layer any autowake on top only where the host allows.
### 8.4 Forge integration — read **and write** (`internal/forge`)
Talking to the hosting provider is isolated behind a **provider interface**
(**Gitea/Forgejo first** — the primary host is `git.nilles.net`; GitHub/GitLab
later behind the same interface).
- **Read:** list open PRs/MRs and their CI/check status for a repo whose remote
points at a supported host.
- **Write (enabled):** create a PR, **merge a PR, and delete the source branch**
— this powers "**Merge & clean up**", the feature that makes PRs usable for a
user who otherwise finds them clutter (Section 0). Every write is **confirmed
per §1.4**, names the PR/branch, and prefers the tidy default (squashmerge +
delete branch). Note a merged PR remains in the host's history; "clean up"
means removing the **branch**, not falsifying history.
- **Enablement is perhost and tokengated.** Tokens come from `.env`
(e.g. `GITHUB_TOKEN`, `GITLAB_TOKEN`); with no token, the feature is simply
absent and the rest of the app works unchanged (**graceful degradation** — never
a hard dependency).
- The provider is inferred from a repo's remote URL. Never send repo data to a host
the user didn't configure.
Design the interface cleanly now; implement providers incrementally as requested.
(e.g. `GITEA_TOKEN`); with no token the forge features are simply absent and
the rest of the app works unchanged (**graceful degradation**).
- The provider is inferred from a repo's remote URL. Never send repo data to a
host the user didn't configure.
---
@@ -373,9 +499,12 @@ component carries its own context.
2. **Check the rules** in Section 1. If the request conflicts, **pause and ask.**
3. If the work is UI: it is a **web component** in `components/<name>/` with its
own `.md`. No exceptions without asking.
4. If it touches repositories: go through the **`internal/git` boundary**, respect
**Git = system of record** (Section 1.3), and treat **destructive operations**
per Section 1.4. Never add a datastore.
4. If it touches repositories or forges: put the logic in the **service layer**
(§1.7) over the `internal/git` / `internal/forge` boundaries — never in an HTTP
or MCP handler — so both the GUI and Claude get it. Respect **Git = system of
record** (§1.3), treat **destructive operations** per §1.4, and never add a
datastore. A new capability generally means: service method → HTTP handler →
MCP tool → UI control.
5. If it needs a **new dependency** or a **new toplevel folder**, propose it and
wait for approval.
6. Implement, run it in the **dockercompose dev environment**, verify hot reload
@@ -396,10 +525,29 @@ silently guess.)*
it should be enabled, and the interval / rate limit, before turning it on.
- **Git access library:** system `git` via `os/exec` is the locked default
(Section 2). Confirm before introducing `go-git` for any read path.
- **Forge providers:** which to support first (GitHub? GitLab?), and whether any
**write** actions (merge/comment/approve) are ever in scope (default: readonly).
- **Listen address / exposure:** localhostonly by default. Confirm before binding
to a nonlocal interface — there is no auth (Section 0).
- ✅ **RESOLVED 2026-09-19:** **Forge = Gitea/Forgejo first** (`git.nilles.net`),
**read + write** — merge PR + delete branch ("Merge & clean up"), each confirmed
per §1.4 (Section 8.4). GitHub/GitLab later behind the same interface.
- ✅ **RESOLVED 2026-09-19:** **MCP transport = Streamable HTTP at `/mcp`**, added
in Claude Desktop as a custom connector (Section 8.1).
- ✅ **RESOLVED 2026-09-19:** **Project handoff is cooperative** — user requests a
switch, Claude finishes to a safe checkpoint, switches, and the user is notified;
pullfirst, not autonomous (Section 8.3).
- ✅ **RESOLVED 2026-09-20:** Claude Desktop's **GUI "custom connector" cannot
reach a localhost server** — it validates/calls from Anthropic's cloud. Solved
with the **local stdio bridge** (`claude_desktop_config.json` → `mcpServers` →
`mcp-remote http://127.0.0.1:8080/mcp`), §8.1. (HTTPS on `:8443` via mkcert was
added earlier and still works, but is not required for this path.)
- **Idletrigger for handoff:** the pull model syncs at Claude's turn boundaries.
If Claude is idle when the user switches, decide the nudge (user's next message,
a heartbeat/poll, or a host push if available) — do not assume instant wake.
- **Coordinationstate lifetime:** active project / pendingswitch / activity feed
are inmemory today (§1.3). Confirm if any must survive an app restart before
adding any persistence.
- **Gitea token scope:** which token scopes to require (repo read + PR write +
branch delete) and how it is provisioned; document in `.env.example`.
- **Listen address / exposure:** localhostonly by default (covers `/mcp` too).
Confirm before binding to a nonlocal interface — there is no auth (Section 0).
- **Credential path from the container:** SSH agent socket vs mounted keys vs
credential helper, for pushing/fetching from inside Docker.
+169
View File
@@ -32,3 +32,172 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
canonical import path (e.g. a GitHub URL).
- All items in AGENT.md §11 (discovery strategy, background fetch, forge
providers, listen address, container credentials) remain open.
## 2026-09-19 — Redefine the app as a two-way Claude companion (contract only)
- **What:** Updated AGENT.md to make the Claude integration the defining pillar,
no code yet. §0 now states the two-way purpose (Claude↔app) and the
non-expert, GUI-first goal; added law §1.7 (one service layer behind both the
GUI and the MCP server); stack table gained MCP server (Go SDK over Streamable
HTTP at `/mcp`), SSE (app→browser), and flipped forge to Gitea-first read+write;
layout added `internal/{service,mcp,activity}`; §6 added the right-click
plain-language command vocabulary; **§8 rewritten** into "Claude integration"
(8.1 MCP server, 8.2 activity feed + active project, 8.3 graceful project
handoff, 8.4 forge read+write with "Merge & clean up"); §10 step 4 and §11
updated (three decisions resolved, new open items). `.env.example` now documents
`GITEA_TOKEN` (read+write scope).
- **Why:** Thomas described the real vision — the app should act as an extension
of Claude: usable like an MCP by Claude, notifying Claude of in-app actions to
stay in sync, cooperative project handoff when he's interrupted, plain-language
right-click commands for non-experts, and one-click "merge & clean up" so PRs
stop cluttering repos. Decisions locked: cooperative pull-first handoff; Gitea
writes enabled (confirmed per §1.4); MCP over HTTP `/mcp`.
- **Affects:** `AGENT.md`, `.env.example` (architecture/contract only — no code).
## 2026-09-19 — Service layer + MCP server (Claude integration, read tools)
- **What:** Slice 1 — extracted `internal/service`, the one capability layer both
the HTTP API and the MCP server call (§1.7); the `/api/repos` and `/api/repo`
handlers now route through it. Slice 2 — added `internal/mcp`: an MCP server
(`github.com/modelcontextprotocol/go-sdk` v1.8.0) served over Streamable HTTP at
`/mcp`, with read tools `list_repos` and `get_repo` as thin adapters over the
service. Added a round-trip test (`internal/mcp/mcp_test.go`) using a real temp
git repo + the in-memory MCP transport. Verified the HTTP `/mcp` handshake
locally and in Docker.
- **Why:** First step of the two-way Claude integration (AGENT.md §8.1) — prove
Claude can connect to the app over MCP before building deeper features on it.
- **Affects:** `internal/service` (new), `internal/mcp` (new), `cmd/server/main.go`,
`go.mod`/`go.sum`, `.air.toml`.
- **Gotcha:** Docker-on-Windows bind mounts do NOT deliver filesystem events, so
air's watch-based reload silently never fired. Fixed by enabling air polling
(`poll = true`, `poll_interval = 500` in `.air.toml`).
## 2026-09-20 — HTTPS for the MCP connector (local TLS via mkcert)
- **What:** Added an optional HTTPS listener alongside HTTP. New config
`HTTPS_ADDR`, `TLS_CERT_FILE`, `TLS_KEY_FILE`; when set, `cmd/server` starts
`e.StartTLS` on the same Echo app (best-effort — a missing cert logs a warning
and stays HTTP-only). docker-compose publishes `127.0.0.1:8443` and points the
TLS vars at `certs/localhost.pem` (mounted via the existing source mount).
`.gitignore` ignores `/certs/`; `.env.example` documents the mkcert steps.
- **Why:** Claude Desktop's custom MCP connector only accepts `https://` URLs.
Local TLS with an mkcert-trusted cert lets `https://localhost:8443/mcp` work
without exposing the unauthenticated app via a public tunnel (AGENT.md §8.1).
- **Affects:** `internal/config`, `cmd/server/main.go`, `docker-compose.yml`,
`.gitignore`, `.env.example`, `AGENT.md` (§8.1, §11).
- **Host setup (user-run):** the local CA install (`mkcert -install`) is a
security-settings change performed by the user, not the app.
## 2026-09-20 — Connect Claude Desktop via local stdio bridge (mcp-remote)
- **What:** Corrected the Claude Desktop connection method in AGENT.md (§8.1, §11).
The GUI "Add custom connector" flow can NOT reach a localhost server — it probes
and calls tools from Anthropic's cloud, so `https://127.0.0.1:8443/mcp` fails
"couldn't reach the server" even though a local browser reaches it. The working
path is a local stdio bridge in `claude_desktop_config.json`:
`mcpServers.gitmanager = cmd /c npx -y mcp-remote http://127.0.0.1:8080/mcp`.
Added that entry to the user's Claude Desktop config (backup saved alongside).
- **Why:** Keep the app localhost-only + unauthenticated (§0) while still letting
Claude Desktop drive it. `mcp-remote` runs locally, so it reaches the local
endpoint directly — no public exposure, no HTTPS needed for this path.
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside
the repo). HTTPS/`:8443` from the prior entry stays available but is now optional.
## 2026-09-20 — Slice 3: activity feed + active project (§8.2)
- **What:** Added `internal/activity` (thread-safe active project + bounded event
feed with subscriber fan-out, mirrored to logs, no datastore). Service gained
`ActiveProject`/`SetActiveProject`/`RecordActivity`/`Activity`/`SubscribeActivity`
(and `service.New` now takes the feed). New MCP tools `get_active_project`,
`set_active_project`, `get_activity` (object-wrapped outputs). New HTTP:
`GET/POST /api/active-project`, `GET /api/activity`, and `GET /events` (SSE).
New `<activity-feed>` component (live via EventSource); `<repo-list>` now sets
the active project on selection (a user action). Extended the MCP test to cover
the new tools; help page documents the feature.
- **Why:** The coordination foundation for the graceful project handoff (§8.3):
the app and Claude share one active-project + activity view. User actions are
recorded as `actor:user`, Claude's as `actor:claude`, so each side can see what
the other did.
- **Affects:** `internal/activity` (new), `internal/service`, `internal/mcp`
(+test), `cmd/server/main.go`, `components/activity-feed` (new),
`components/repo-list`, `web/templates/{index,help}.html`.
## 2026-09-20 — Slice 4: graceful project handoff (§8.3)
- **What:** `internal/activity` gained a pending-switch model
(`RequestSwitch`/`PendingSwitch`/`AckSwitch`/`CancelSwitch`); `AckSwitch`
atomically sets the active project to the requested target and records a
`switch-completed` event with Claude's summary. Service methods added. New MCP
tools `get_pending_switch` and `ack_switch` (request is user-only — no MCP tool
raises it). HTTP: `GET/POST/DELETE /api/switch`. New `<handoff-bar>` component:
"Ask Claude to switch to <active>", the "waiting for a good stopping point"
state with Cancel, and the completion notice; `<activity-feed>` also updates the
active project on `switch-completed`. Extended the MCP test to cover the full
request→ack→clear flow. Help page + AGENT.md §8.3 updated.
- **Why:** The headline feature — the user asks Claude to switch projects; Claude
finishes to a safe stopping point, then `ack_switch` completes it and the app
notifies the user over SSE. The switch is Claude-completed at a checkpoint,
never app-forced (§1.4-class rule).
- **Affects:** `internal/activity`, `internal/service`, `internal/mcp` (+test),
`cmd/server/main.go`, `components/handoff-bar` (new), `components/activity-feed`,
`web/templates/{index,help}.html`, `AGENT.md` (§8.3).
- **Verified live:** request → "waiting" → cancel, all over SSE with activity
logging. The ack/completion path is covered by the test; its live ✅ notice
needs the two new MCP tools, which appear after the next Claude Desktop restart.
## 2026-09-20 — Slice 5: Gitea forge — PRs + "Merge & clean up" (§8.4)
- **What:** New `internal/forge` — provider-abstracted forge boundary with a Gitea
impl (`code.gitea.io/sdk/gitea`), a remote-URL parser (`ParseRemote`, tested),
and read+write ops: `ListPullRequests` and `MergeAndCleanup` (squash-merge +
delete the head branch, only when head/base share a repo). Service resolves a
repo → owner/repo via its remotes (prefers origin) and records a `pr-merged`
activity event. New config `GITEA_URL` + `GITEA_TOKEN` (forge is nil/disabled
without both). New MCP tools `list_prs` and `merge_and_cleanup_pr` (the merge
tool's description tells Claude to confirm first, §1.4). HTTP
`GET /api/repo/prs`, `POST /api/repo/pr/merge`. New `<pr-list>` component with a
confirming "Merge & clean up" button; hidden when no forge is configured.
- **Why:** The feature Thomas asked for — make PRs usable by merging and removing
the branch in one tidy step, from the app or via Claude.
- **Affects:** `internal/forge` (new, +test), `internal/config`,
`internal/service`, `internal/mcp`, `cmd/server/main.go`,
`components/pr-list` (new), `web/templates/{index,help}.html`, `.env.example`,
`go.mod`.
- **Not yet live-tested:** needs `GITEA_URL`+`GITEA_TOKEN` set and a real PR;
build/vet/tests pass and the parser is unit-tested. A real merge is irreversible
— will only run one against a PR Thomas designates, with confirmation.
## 2026-09-20 — Forge live-tested (Merge & clean up)
- **What:** With `GITEA_URL`+`GITEA_TOKEN` set, verified end-to-end against
git.nilles.net: created an isolated throwaway PR via the Gitea API (on a
dedicated base branch so `main` was untouched), listed it through `GET
/api/repo/prs`, then ran `POST /api/repo/pr/merge``{merged:true,
branchDeleted:true}`; confirmed the branch was gone (404), the PR list emptied,
and the feed logged `pr-merged`. Cleaned up the base branch afterward.
- **Why:** Prove the write path with real auth before relying on it.
- **Affects:** none (runtime verification only; no code change).
## 2026-09-20 — Slice 6: right-click command menu + git write actions (§6)
- **What:** git boundary gained `Pull`/`Push`/`Commit`/`DiscardAll` (the last is
§1.4-destructive). Scanner got `RefreshRepo` (single-repo re-scan). Service
gained `GitFetch/GitPull/GitPush/GitCommit/GitDiscard` — each records a `git-*`
activity event (ok/failed) and refreshes the repo after success; `service.New`
takes a refresh hook. New HTTP `POST /api/repo/git {path, op, message?}`. New
`<repo-menu>` overlay (plain-language commands: Get latest, Publish, Check for
updates, Save my work…, Set as active project, Ask Claude to switch here, Copy
path, and the confirmed Discard all changes…); `<repo-list>` emits
`repo:contextmenu` on right-click. Added `internal/service` test covering
commit/discard on a temp repo.
- **Why:** The GUI-first reason the app exists (§0) — run git in plain language
without a terminal. Logic lives in the shared service (§1.7) so the same ops can
be exposed to Claude via MCP next.
- **Affects:** `internal/git`, `internal/repos`, `internal/service` (+test),
`cmd/server/main.go`, `components/repo-menu` (new), `components/repo-list`,
`web/templates/{index,help}.html`.
- **Next:** expose these git ops as MCP tools so Claude can run them too.
## 2026-09-20 — Slice 7: git commands as MCP tools (§1.7 symmetry)
- **What:** Added MCP tools `git_fetch`, `git_pull`, `git_push`, `git_commit`,
and `git_discard_changes` — thin adapters over the existing service methods
(actor=claude), so Claude can run the same commands as the right-click menu.
`git_discard_changes`'s description flags it destructive and tells Claude to
confirm first (§1.4). Extended the MCP test with a `git_commit` round-trip.
Synced AGENT.md §8.1's tool list to the actual names.
- **Why:** Complete the §1.7 symmetry — every capability reachable from both the
GUI and Claude.
- **Affects:** `internal/mcp` (+test), `AGENT.md` (§8.1).
- **Note:** the new tools appear in Claude Desktop only after its next restart
(tool list cached per connection); network ops still need container git creds.
+198 -8
View File
@@ -5,21 +5,25 @@ package main
import (
"context"
"encoding/json"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"gitmanager/internal/activity"
"gitmanager/internal/config"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/logging"
mcpserver "gitmanager/internal/mcp"
"gitmanager/internal/render"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
func main() {
@@ -50,6 +54,23 @@ func main() {
go scanner.Run(scanCtx)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
// Coordination state: active project + activity feed (§8.2).
feed := activity.New(log, 200)
// Forge provider (Gitea) — optional; nil when unconfigured (§8.4).
fg, err := forge.NewGitea(cfg.GiteaURL, cfg.GiteaToken)
if err != nil {
log.Warn("forge disabled — invalid config", "err", err)
} else if fg != nil {
log.Info("forge enabled", "provider", "gitea", "url", cfg.GiteaURL)
} else {
log.Info("forge disabled — set GITEA_URL and GITEA_TOKEN to enable")
}
// The one service layer both the HTTP API and the MCP server call (§1.7).
// scanner.RefreshRepo lets a mutating action re-scan just that repo.
svc := service.New(g, scanner.Index, feed, fg, scanner.RefreshRepo)
tmpl, err := render.New("web/templates")
if err != nil {
log.Error("failed to parse templates", "err", err)
@@ -79,20 +100,173 @@ func main() {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
e.GET("/api/repos", func(c echo.Context) error {
return c.JSON(http.StatusOK, scanner.Index.List())
return c.JSON(http.StatusOK, svc.ListRepos())
})
e.GET("/api/repo", func(c echo.Context) error {
// Only serve details for a repo we already discovered — never run git
// against an arbitrary path supplied in the query string. Clean the
// input so separator style (/, \) doesn't defeat the exact-match lookup.
path := filepath.Clean(c.QueryParam("path"))
base, ok := scanner.Index.Get(path)
// The service only serves details for an already-discovered repo — it
// never runs git against an arbitrary caller-supplied path (§1.3).
detail, ok := svc.RepoDetail(c.Request().Context(), c.QueryParam("path"))
if !ok {
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"})
}
return c.JSON(http.StatusOK, repos.BuildDetail(c.Request().Context(), g, base))
return c.JSON(http.StatusOK, detail)
})
// Active project + activity (§8.2).
e.GET("/api/active-project", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
})
e.POST("/api/active-project", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
// A user action in the UI (actor=user) — distinct from Claude's own switches.
if _, _, err := svc.SetActiveProject(activity.ActorUser, body.Path); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
})
e.GET("/api/activity", func(c echo.Context) error {
return c.JSON(http.StatusOK, svc.Activity(0))
})
// Graceful handoff (§8.3): the user requests a switch; Claude completes it.
e.GET("/api/switch", func(c echo.Context) error {
p, ok := svc.PendingSwitch()
if !ok {
return c.JSON(http.StatusOK, map[string]any{"pending": false})
}
return c.JSON(http.StatusOK, map[string]any{
"pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt,
})
})
e.POST("/api/switch", func(c echo.Context) error {
var body struct {
Target string `json:"target"`
Note string `json:"note"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note})
})
e.DELETE("/api/switch", func(c echo.Context) error {
svc.CancelSwitch(activity.ActorUser)
return c.JSON(http.StatusOK, map[string]any{"pending": false})
})
// Plain-language git commands (the right-click menu, §6). One endpoint,
// op-switched. Destructive ops (discard) are confirmed UI-side per §1.4.
e.POST("/api/repo/git", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Op string `json:"op"`
Message string `json:"message"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
ctx := c.Request().Context()
var out string
var err error
switch body.Op {
case "fetch":
out, err = svc.GitFetch(ctx, activity.ActorUser, body.Path)
case "pull":
out, err = svc.GitPull(ctx, activity.ActorUser, body.Path)
case "push":
out, err = svc.GitPush(ctx, activity.ActorUser, body.Path)
case "commit":
out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message)
case "discard":
out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path)
default:
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op})
}
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"ok": true, "output": out})
})
// Forge PRs + "Merge & clean up" (§8.4).
e.GET("/api/repo/prs", func(c echo.Context) error {
prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path"))
if err != nil {
// Not configured / not on the forge host is a normal "no PRs here"
// state — tell the UI to hide the section rather than error.
if err == forge.ErrNotConfigured || err == forge.ErrNotSupported {
return c.JSON(http.StatusOK, map[string]any{"supported": false})
}
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs})
})
e.POST("/api/repo/pr/merge", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Number int64 `json:"number"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
res, err := svc.MergeAndCleanup(c.Request().Context(), activity.ActorUser, body.Path, body.Number)
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, res)
})
// SSE stream of activity events for live UI (§8.2).
e.GET("/events", func(c echo.Context) error {
w := c.Response()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
w.Flush()
ch, unsub := svc.SubscribeActivity()
defer unsub()
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
for {
select {
case <-c.Request().Context().Done():
return nil
case <-keepalive.C:
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
return nil
}
w.Flush()
case ev := <-ch:
data, err := json.Marshal(ev)
if err != nil {
continue
}
if _, err := w.Write([]byte("event: activity\ndata: " + string(data) + "\n\n")); err != nil {
return nil
}
w.Flush()
}
}
})
// MCP server — Claude connects via the local stdio bridge (§8.1). Same
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
log.Info("mcp server mounted", "path", "/mcp")
// Serve with graceful shutdown.
go func() {
if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed {
@@ -102,6 +276,22 @@ func main() {
}()
log.Info("listening", "addr", cfg.ListenAddr)
// Optional HTTPS listener (same Echo app). Required for the MCP connector,
// which only accepts https:// URLs (§8.1). Best-effort: a missing/unreadable
// cert logs a warning and leaves the app running over HTTP.
if cfg.HTTPSAddr != "" && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
if _, err := os.Stat(cfg.TLSCertFile); err != nil {
log.Warn("HTTPS requested but cert not readable — serving HTTP only", "cert", cfg.TLSCertFile, "err", err)
} else {
go func() {
if err := e.StartTLS(cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed {
log.Error("TLS server error", "err", err)
}
}()
log.Info("listening (https)", "addr", cfg.HTTPSAddr)
}
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
+141
View File
@@ -0,0 +1,141 @@
// <activity-feed> — shows the active project and a live feed of what happened
// (user AND Claude actions). A self-contained control (AGENT.md §1.1): shadow
// DOM, fetches its own initial data, subscribes to the /events SSE stream, and
// cleans up on disconnect. It reflects the coordination state that Claude reads
// over MCP (§8.2), so the user can see the two staying in sync.
class ActivityFeed extends HTMLElement {
#es = null;
#controller = null;
#events = [];
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#loadInitial();
// Live updates. EventSource auto-reconnects if the stream drops.
this.#es = new EventSource('/events');
this.#es.addEventListener('activity', (e) => {
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore malformed */ }
});
}
disconnectedCallback() {
this.#es?.close();
this.#controller?.abort();
}
async #loadInitial() {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const [apRes, actRes] = await Promise.all([
fetch('/api/active-project', { signal: this.#controller.signal }),
fetch('/api/activity', { signal: this.#controller.signal }),
]);
const ap = await apRes.json();
const events = await actRes.json();
this.#events = Array.isArray(events) ? events : [];
this.#renderActive(ap.path || '');
this.#renderFeed();
} catch (err) {
if (err.name !== 'AbortError') this.#renderError(err);
}
}
#onEvent(ev) {
this.#events.push(ev);
if (this.#events.length > 200) this.#events = this.#events.slice(-200);
if (ev.kind === 'active-project-changed' || ev.kind === 'switch-completed') {
this.#renderActive(ev.repo || '');
}
this.#renderFeed();
}
#renderActive(path) {
const el = this.shadowRoot.getElementById('active');
el.textContent = path ? this.#base(path) : 'none';
el.title = path;
}
#renderFeed() {
const ul = this.shadowRoot.getElementById('feed');
// Newest first.
ul.replaceChildren(...[...this.#events].reverse().map((ev) => {
const li = document.createElement('li');
const repo = ev.repo ? this.#base(ev.repo) : '';
li.innerHTML = `
<span class="actor ${this.#esc(ev.actor)}">${this.#esc(ev.actor)}</span>
<span class="kind">${this.#esc(this.#label(ev.kind))}</span>
${repo ? `<span class="repo">${this.#esc(repo)}</span>` : ''}
${ev.detail ? `<span class="detail">${this.#esc(ev.detail)}</span>` : ''}
<span class="time">${this.#time(ev.time)}</span>`;
return li;
}));
}
#renderError(err) {
this.shadowRoot.getElementById('feed').innerHTML =
`<li class="error">Could not load activity: ${this.#esc(err.message)}</li>`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.box {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
}
.active { margin-bottom: 8px; color: var(--color-fg-muted); }
.active strong { color: var(--fill-accent); }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 8px 0 6px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px;
max-height: 220px; overflow-y: auto; }
li { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
.actor { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); text-transform: uppercase; }
.actor.user { color: var(--git-ahead); border-color: var(--git-ahead); }
.actor.claude { color: var(--color-success); border-color: var(--color-success); }
.actor.system { color: var(--color-fg-muted); }
.repo { font-weight: 600; }
.detail { color: var(--color-fg-muted); }
.time { margin-left: auto; color: var(--color-fg-muted); font-size: 11px; white-space: nowrap; }
.error { color: var(--color-danger); }
.empty { color: var(--color-fg-muted); }
</style>
<div class="box">
<div class="active">Active project: <strong id="active">none</strong></div>
<h3>Activity</h3>
<ul id="feed"><li class="empty">No activity yet.</li></ul>
</div>`;
}
#base(p) {
return String(p).split(/[/\\]/).filter(Boolean).pop() || p;
}
#label(kind) {
return String(kind || '').replace(/-/g, ' ');
}
#time(t) {
const d = new Date(t);
return isNaN(d) ? '' : d.toLocaleTimeString();
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('activity-feed', ActivityFeed);
+30
View File
@@ -0,0 +1,30 @@
# activity-feed
## Intent
Shows the **active project** and a **live feed** of what happened — both user and
Claude actions. It makes the coordination state visible (AGENT.md §8.2): the same
active project and events Claude reads over MCP (`get_active_project`,
`get_activity`), so the user can watch the two stay in sync. Groundwork for the
graceful project handoff (§8.3).
## Public surface
- **Tag:** `<activity-feed>`
- **Attributes/properties:** none.
- **Fetches (initial):** `GET /api/active-project`, `GET /api/activity`.
- **Subscribes:** `EventSource('/events')` — SSE stream; listens for `activity`
events (auto-reconnects if the stream drops). Closed on disconnect.
- **Renders:** active project (basename, full path on hover) + newest-first list
of events, each with an actor badge (user/claude/system), a humanized kind,
the repo, optional detail, and a timestamp.
## History
- 2026-09-20: created — slice 3; live active-project + activity view over SSE.
- 2026-09-20: also update the active-project display on `switch-completed` events
(slice 4 handoff), not only `active-project-changed`.
## Notes / gotchas
- All server-derived text is escaped before insertion (repo paths, details,
kinds) — treat as untrusted (§1.1).
- Uses shared design tokens for colors/radii — no hardcoded hex.
- The feed is capped client-side at 200 events to mirror the server ring buffer;
it does not paginate history.
+155
View File
@@ -0,0 +1,155 @@
// <handoff-bar> — the graceful project handoff control (AGENT.md §8.3).
//
// A self-contained control (§1.1): shadow DOM, self-fetching, live over SSE,
// cleans up on disconnect. It lets the user ask Claude to switch to the active
// project, shows the "waiting for a good stopping point" state while a request
// is pending, and announces the completion (with Claude's summary of where it
// left off) when Claude calls ack_switch.
class HandoffBar extends HTMLElement {
#es = null;
#controller = null;
#active = '';
#pending = null;
#completed = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#loadInitial();
this.#es = new EventSource('/events');
this.#es.addEventListener('activity', (e) => {
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore */ }
});
}
disconnectedCallback() {
this.#es?.close();
this.#controller?.abort();
}
async #loadInitial() {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const [swRes, apRes] = await Promise.all([
fetch('/api/switch', { signal: this.#controller.signal }),
fetch('/api/active-project', { signal: this.#controller.signal }),
]);
const sw = await swRes.json();
const ap = await apRes.json();
this.#pending = sw.pending ? sw : null;
this.#active = ap.path || '';
this.#render();
} catch (err) {
if (err.name !== 'AbortError') { /* keep default UI */ }
}
}
#onEvent(ev) {
switch (ev.kind) {
case 'switch-requested':
this.#pending = { target: ev.repo, note: ev.detail };
this.#completed = null;
break;
case 'switch-completed':
this.#pending = null;
this.#active = ev.repo || this.#active;
this.#completed = { target: ev.repo, summary: ev.detail };
break;
case 'switch-cancelled':
this.#pending = null;
break;
case 'active-project-changed':
this.#active = ev.repo || '';
break;
default:
return;
}
this.#render();
}
async #request() {
if (!this.#active) return;
await fetch('/api/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: this.#active }),
}).catch(() => {});
// The SSE switch-requested event updates the UI.
}
async #cancel() {
await fetch('/api/switch', { method: 'DELETE' }).catch(() => {});
}
#render() {
const body = this.shadowRoot.getElementById('body');
if (this.#pending) {
body.innerHTML = `
<span class="spinner">⏳</span>
<span>Waiting for Claude to reach a good stopping point to switch to
<strong>${this.#esc(this.#base(this.#pending.target))}</strong>…</span>
<button id="cancel" class="ghost">Cancel</button>`;
this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel();
return;
}
const done = this.#completed
? `<span class="done">✅ Claude switched to
<strong>${this.#esc(this.#base(this.#completed.target))}</strong>${
this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : ''
}</span>`
: '';
if (!this.#active) {
body.innerHTML = `${done}<span class="hint">Select a repository to make it the active project, then hand it off to Claude.</span>`;
return;
}
body.innerHTML = `
${done}
<button id="ask" class="primary">Ask Claude to switch to ${this.#esc(this.#base(this.#active))}</button>`;
this.shadowRoot.getElementById('ask').onclick = () => this.#request();
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
#body {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
}
button { font: inherit; border-radius: var(--radius-sm); cursor: pointer;
padding: 5px 12px; border: 1px solid var(--border-strong);
background: var(--surface-2); color: var(--color-fg); }
button.primary { border-color: var(--fill-accent); color: var(--fill-accent); }
button.ghost { color: var(--color-fg-muted); }
button:hover { border-color: var(--fill-accent); }
.spinner { font-size: 15px; }
.done { color: var(--color-success); }
.hint { color: var(--color-fg-muted); }
strong { color: var(--fill-accent); }
#cancel { margin-left: auto; }
</style>
<div id="body"></div>`;
}
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('handoff-bar', HandoffBar);
+28
View File
@@ -0,0 +1,28 @@
# handoff-bar
## Intent
The user-facing control for the **graceful project handoff** (AGENT.md §8.3). It
lets the user ask Claude to switch to the active project, shows the "waiting for a
good stopping point" state while the request is pending, and announces completion
(with Claude's summary of where it left off) when Claude calls `ack_switch`. The
switch is Claude-completed at a checkpoint, never app-forced (§1.4-class rule).
## Public surface
- **Tag:** `<handoff-bar>`
- **Attributes/properties:** none.
- **Fetches (initial):** `GET /api/switch` (pending request), `GET /api/active-project`.
- **Writes:** `POST /api/switch {target}` to request a handoff to the active
project (actor=user); `DELETE /api/switch` to cancel.
- **Subscribes:** `EventSource('/events')` — reacts to `switch-requested`,
`switch-completed` (shows Claude's summary), `switch-cancelled`, and
`active-project-changed`. Closed on disconnect.
## History
- 2026-09-20: created — slice 4; request/pending/completed UI over the
`/api/switch` endpoints and SSE.
## Notes / gotchas
- All server-derived text is escaped before insertion (targets, summaries).
- The request targets the current **active project**; select a repo first (that
sets the active project via `<repo-list>`).
- The completion message persists until the next request; it is informational.
+139
View File
@@ -0,0 +1,139 @@
// <pr-list> — open pull requests for the selected repo, with "Merge & clean up".
//
// A self-contained control (AGENT.md §1.1): shadow DOM, self-fetching, cleans up
// on disconnect. It listens for `repo:select` and shows the repo's open PRs when
// a forge (Gitea) is configured; otherwise it stays hidden (graceful, §8.4).
// "Merge & clean up" is a DESTRUCTIVE action (§1.4): it confirms — naming the PR,
// base, and branch to be deleted — before calling the server.
class PRList extends HTMLElement {
#controller = null;
#onSelect = null;
#path = '';
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#onSelect = (e) => this.#load(e.detail?.path);
document.addEventListener('repo:select', this.#onSelect);
}
disconnectedCallback() {
document.removeEventListener('repo:select', this.#onSelect);
this.#controller?.abort();
}
async #load(path) {
if (!path) return;
this.#path = path;
this.#controller?.abort();
this.#controller = new AbortController();
try {
const res = await fetch(`/api/repo/prs?path=${encodeURIComponent(path)}`, { signal: this.#controller.signal });
const data = await res.json();
if (!data.supported) { this.hidden = true; return; }
this.hidden = false;
this.#render(data.prs || []);
} catch (err) {
if (err.name !== 'AbortError') { this.hidden = false; this.#error(err.message); }
}
}
async #merge(pr) {
const ok = window.confirm(
`Merge & clean up PR #${pr.number}: "${pr.title}"?\n\n` +
`This squash-merges it into ${pr.base} and DELETES the branch "${pr.head}".\n` +
`This cannot be undone.`
);
if (!ok) return;
try {
const res = await fetch('/api/repo/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#path, number: pr.number }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#load(this.#path); // refresh the list
} catch (err) {
this.#error(`Merge failed: ${err.message}`);
}
}
#render(prs) {
const body = this.shadowRoot.getElementById('body');
if (prs.length === 0) {
body.innerHTML = `<p class="muted">No open pull requests.</p>`;
return;
}
body.replaceChildren(...prs.map((pr) => {
const li = document.createElement('li');
li.innerHTML = `
<div class="row">
<a class="num" href="${this.#esc(pr.url)}" target="_blank" rel="noopener">#${pr.number}</a>
<span class="title">${this.#esc(pr.title)}</span>
${pr.draft ? `<span class="badge draft">draft</span>` : ''}
</div>
<div class="meta">
${this.#esc(pr.author)} · <code>${this.#esc(pr.head)}</code> → <code>${this.#esc(pr.base)}</code>
${pr.sameRepo ? '' : `<span class="badge fork">fork</span>`}
</div>`;
const btn = document.createElement('button');
btn.textContent = 'Merge & clean up';
btn.className = 'merge';
btn.title = pr.draft ? 'This PR is a draft' : 'Squash-merge and delete the branch';
btn.onclick = () => this.#merge(pr);
li.querySelector('.row').appendChild(btn);
return li;
}));
}
#error(msg) {
this.shadowRoot.getElementById('body').innerHTML = `<p class="error">${this.#esc(msg)}</p>`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.box { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 0 0 8px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
li { border-top: 1px solid var(--border); padding-top: 8px; }
li:first-child { border-top: none; padding-top: 0; }
.row { display: flex; align-items: center; gap: 8px; }
.num { color: var(--fill-accent); text-decoration: none; font-weight: 600; }
.title { flex: 1; }
.meta { color: var(--color-fg-muted); font-size: 12px; margin-top: 2px; }
code { background: var(--surface-2); padding: 0 5px; border-radius: var(--radius-sm); }
.badge { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); }
.draft { color: var(--color-warning); border-color: var(--color-warning); }
.fork { color: var(--color-fg-muted); margin-left: 6px; }
button.merge { font: inherit; cursor: pointer; padding: 4px 10px;
border-radius: var(--radius-sm); border: 1px solid var(--color-danger);
color: var(--color-danger); background: transparent; }
button.merge:hover { background: var(--color-danger-bg); }
.muted { color: var(--color-fg-muted); }
.error { color: var(--color-danger); }
</style>
<div class="box">
<h3>Pull requests</h3>
<div id="body"><p class="muted">Select a repository.</p></div>
</div>`;
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('pr-list', PRList);
+26
View File
@@ -0,0 +1,26 @@
# pr-list
## Intent
Shows the open pull requests for the selected repository and provides the
**"Merge & clean up"** action (AGENT.md §8.4) — the feature that makes PRs usable
for someone who otherwise finds them clutter: squash-merge and delete the branch
in one click. The merge is DESTRUCTIVE (§1.4), so the button confirms first,
naming the PR, base, and branch to be deleted.
## Public surface
- **Tag:** `<pr-list>`
- **Attributes/properties:** none. Hides itself (`hidden`) when no forge is
configured or the repo isn't on the forge host.
- **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`.
- **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden).
- **Writes:** `POST /api/repo/pr/merge {path, number}` after a `confirm()`.
## History
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up".
## Notes / gotchas
- Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component
stays hidden (graceful degradation).
- Fork PRs are labelled; their branch lives in the fork, so cleanup only deletes
branches in the same repo (the server enforces this too).
- All server-derived text is escaped; the PR link opens in a new tab.
+16
View File
@@ -47,6 +47,13 @@ class RepoList extends HTMLElement {
this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true,
}));
// Selecting a repo makes it the active project (a user action, §8.2). Fire
// and forget — the SSE feed reflects the change; failure just skips it.
fetch('/api/active-project', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: repo.path }),
}).catch(() => {});
}
#renderShell() {
@@ -98,6 +105,15 @@ class RepoList extends HTMLElement {
for (const r of this.#repos) {
const li = document.createElement('li');
if (r.path === this.#selected) li.classList.add('selected');
// Right-click opens the command menu (§6) for this repo — via an event,
// so <repo-menu> stays decoupled from this component (§1.1).
li.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('repo:contextmenu', {
detail: { repo: r, x: e.clientX, y: e.clientY },
bubbles: true, composed: true,
}));
});
li.innerHTML = `
<span class="name">${this.#esc(r.name)}</span>
<span class="branch">${this.#esc(r.branch || '—')}</span>
+5
View File
@@ -21,6 +21,11 @@ component pattern the rest of the UI follows.
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an
accent border/background (the item that `<repo-detail>` is showing). Caches the
last `/api/repos` payload so re-selecting re-renders without a refetch.
- 2026-09-20: selecting a repo now also `POST`s `/api/active-project` to make it
the active project (a user action, §8.2) — surfaced in `<activity-feed>` and
readable by Claude via `get_active_project`. Fire-and-forget.
- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for
`<repo-menu>` (§6). Right-click does not change the selection/active project.
## Notes / gotchas
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
+176
View File
@@ -0,0 +1,176 @@
// <repo-menu> — the right-click command menu (AGENT.md §6).
//
// A self-contained control (§1.1): shadow DOM, listens for the bubbling
// `repo:contextmenu` event from <repo-list>, and shows a positioned menu of
// PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run
// on click; the destructive one ("Discard all changes") confirms first (§1.4).
// It calls the same endpoints Claude uses via MCP (one service layer, §1.7);
// results show up live in <activity-feed>.
const ITEMS = [
{ cmd: 'pull', label: 'Get latest', hint: 'pull' },
{ cmd: 'push', label: 'Publish', hint: 'push' },
{ cmd: 'fetch', label: 'Check for updates', hint: 'fetch' },
{ cmd: 'commit', label: 'Save my work…', hint: 'commit' },
{ sep: true },
{ cmd: 'active', label: 'Set as active project' },
{ cmd: 'handoff', label: 'Ask Claude to switch here' },
{ cmd: 'copy', label: 'Copy path' },
{ sep: true },
{ cmd: 'discard', label: 'Discard all changes…', hint: 'reset --hard', danger: true },
];
class RepoMenu extends HTMLElement {
#repo = null;
#onContext = null;
#onDocClick = null;
#onKey = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#onContext = (e) => this.#open(e.detail);
document.addEventListener('repo:contextmenu', this.#onContext);
}
disconnectedCallback() {
document.removeEventListener('repo:contextmenu', this.#onContext);
this.#teardownDismiss();
}
#open({ repo, x, y }) {
if (!repo) return;
this.#repo = repo;
const menu = this.shadowRoot.getElementById('menu');
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
menu.hidden = false;
// Position, clamped to the viewport.
const rect = menu.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8);
menu.style.left = Math.max(8, left) + 'px';
menu.style.top = Math.max(8, top) + 'px';
// Dismiss on next outside click, Esc, or scroll.
this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); };
this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); };
setTimeout(() => {
document.addEventListener('click', this.#onDocClick, { once: true });
document.addEventListener('keydown', this.#onKey);
window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true });
}, 0);
}
#hideBound() { return () => this.#hide(); }
#hide() {
this.shadowRoot.getElementById('menu').hidden = true;
this.#teardownDismiss();
}
#teardownDismiss() {
if (this.#onDocClick) document.removeEventListener('click', this.#onDocClick);
if (this.#onKey) document.removeEventListener('keydown', this.#onKey);
this.#onDocClick = this.#onKey = null;
}
async #dispatch(cmd) {
const repo = this.#repo;
const name = this.#base(repo.path);
this.#hide();
switch (cmd) {
case 'pull': case 'push': case 'fetch':
await this.#git(cmd);
break;
case 'commit': {
const msg = window.prompt(`Commit message for ${name}:`);
if (msg && msg.trim()) await this.#git('commit', { message: msg });
break;
}
case 'discard': {
const ok = window.confirm(
`Discard ALL uncommitted changes in ${name}?\n\n` +
`This resets tracked files to the last commit and cannot be undone.`
);
if (ok) await this.#git('discard');
break;
}
case 'active':
await this.#post('/api/active-project', { path: repo.path });
break;
case 'handoff':
await this.#post('/api/switch', { target: repo.path });
break;
case 'copy':
try { await navigator.clipboard.writeText(repo.path); } catch { /* ignore */ }
break;
}
}
async #git(op, extra = {}) {
try {
const res = await fetch('/api/repo/git', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#repo.path, op, ...extra }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
} catch (err) {
window.alert(`${op} failed: ${err.message}`);
}
}
async #post(url, body) {
try {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch { /* ignore */ }
}
#renderShell() {
const rows = ITEMS.map((it) => it.sep
? '<hr>'
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
</button>`).join('');
this.shadowRoot.innerHTML = `
<style>
#menu {
position: fixed; z-index: 1000; min-width: 220px;
background: var(--surface-2); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 4px;
box-shadow: 0 8px 28px rgba(0,0,0,.45);
}
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
border-bottom: 1px solid var(--border); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button { display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; color: var(--color-fg); font: inherit;
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
cursor: pointer; }
button:hover { background: var(--fill-accent); color: #071019; }
button code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
button:hover code { color: #071019; }
button.danger { color: var(--color-danger); }
button.danger:hover { background: var(--color-danger); color: #fff; }
button.danger:hover code { color: #fff; }
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
</style>
<div id="menu" hidden>
<div class="hdr" id="hdr"></div>
${rows}
</div>`;
this.shadowRoot.getElementById('menu').addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (btn) this.#dispatch(btn.dataset.cmd);
});
}
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
}
customElements.define('repo-menu', RepoMenu);
+32
View File
@@ -0,0 +1,32 @@
# repo-menu
## Intent
The right-click command menu (AGENT.md §6) — the app's reason for being: run git
in **plain language** ("Get latest", "Publish", "Save my work…") without a
terminal. A self-contained overlay control (§1.1) that any list can summon via an
event. Safe commands run on click; the destructive one confirms first (§1.4).
## Public surface
- **Tag:** `<repo-menu>` (place once, near the end of the page).
- **Listens:** `repo:contextmenu` on `document``detail: { repo, x, y }`
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
- **Commands → endpoints:**
- Get latest / Publish / Check for updates / Save my work… / Discard all
changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/
commit/discard). "Save my work…" prompts for a message; "Discard all
changes…" confirms (destructive).
- Set as active project → `POST /api/active-project`.
- Ask Claude to switch here → `POST /api/switch` (the handoff request).
- Copy path → clipboard.
- Dismisses on outside click, Esc, or scroll.
## History
- 2026-09-20: created — slice 6; plain-language git commands + coordination
actions, backed by the shared service layer (same ops Claude gets via MCP).
## Notes / gotchas
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a
`git-*` event with ok/failed), and errors raise a browser alert.
- Network ops (pull/push/fetch) need git credentials reachable from the server;
inside Docker that means a mounted SSH agent / credential helper (§11) — until
then they'll report an auth error. commit/discard are local and always work.
+7
View File
@@ -12,10 +12,17 @@ services:
# Bind all interfaces INSIDE the container so the published port reaches
# it; the `ports` mapping below still keeps it localhost-only on the HOST.
- LISTEN_ADDR=0.0.0.0:8080
# HTTPS for the MCP connector (Claude Desktop only accepts https URLs).
# Certs are generated on the host with mkcert (see README/.env.example)
# and mounted read-only below.
- HTTPS_ADDR=0.0.0.0:8443
- TLS_CERT_FILE=/app/certs/localhost.pem
- TLS_KEY_FILE=/app/certs/localhost-key.pem
# The scanner looks here; matches the volume mount below.
- GIT_REPO_ROOTS=/repos
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:8443:8443"
volumes:
# Source, for hot reload.
- .:/app
+12
View File
@@ -3,18 +3,30 @@ module gitmanager
go 1.26
require (
code.gitea.io/sdk/gitea v0.25.1
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.15.4
github.com/modelcontextprotocol/go-sdk v1.8.0
)
require (
github.com/42wim/httpsig v1.2.4 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
+44
View File
@@ -1,5 +1,21 @@
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
@@ -10,23 +26,51 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
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/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+224
View File
@@ -0,0 +1,224 @@
// Package activity holds the app's coordination state: the single active project
// (the repo/task currently in focus) and a bounded feed of what happened — user
// AND Claude actions. Both are in-memory (mirrored to the logs, no datastore —
// AGENT.md §1.3) and queryable so Claude can sync on any turn boundary; new
// events also fan out to subscribers for the browser SSE stream (§8.2). This is
// the foundation the graceful project handoff (§8.3) builds on.
package activity
import (
"log/slog"
"sync"
"time"
)
// Actor is who caused an event.
type Actor string
const (
ActorUser Actor = "user"
ActorClaude Actor = "claude"
ActorSystem Actor = "system"
)
// Event is one entry in the activity feed.
type Event struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Actor Actor `json:"actor"`
Kind string `json:"kind"` // e.g. "active-project-changed"
Repo string `json:"repo,omitempty"` // repo path, when relevant
Detail string `json:"detail,omitempty"` // human-readable extra context
}
// PendingSwitch is a user's request for Claude to switch to another project.
// It is the request half of the graceful handoff (§8.3) — Claude fulfils it at a
// safe stopping point via AckSwitch.
type PendingSwitch struct {
Target string `json:"target"` // requested repo path
Note string `json:"note,omitempty"`
RequestedBy Actor `json:"requestedBy"` // normally the user
RequestedAt time.Time `json:"requestedAt"`
}
// Feed is the concurrency-safe active-project + activity store with fan-out.
type Feed struct {
mu sync.RWMutex
active string // active project path ("" = none)
pending *PendingSwitch // an outstanding switch request, if any
events []Event
maxEvents int
nextID int64
subs map[chan Event]struct{}
log *slog.Logger
}
// New builds a Feed keeping at most maxEvents recent events.
func New(log *slog.Logger, maxEvents int) *Feed {
if maxEvents <= 0 {
maxEvents = 200
}
return &Feed{
maxEvents: maxEvents,
nextID: 1,
subs: make(map[chan Event]struct{}),
log: log,
}
}
// ActiveProject returns the current active project path ("" if none).
func (f *Feed) ActiveProject() string {
f.mu.RLock()
defer f.mu.RUnlock()
return f.active
}
// SetActiveProject sets the active project and records an event. It is a no-op
// (changed=false, zero Event) when path already matches, so repeated sets don't
// spam the feed.
func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
f.mu.Lock()
if f.active == path {
f.mu.Unlock()
return Event{}, false
}
f.active = path
ev, subs := f.appendLocked(actor, "active-project-changed", path, "")
f.mu.Unlock()
publish(subs, ev)
return ev, true
}
// RequestSwitch records a user's request for Claude to switch to target. The
// latest request wins (it overwrites any outstanding one).
func (f *Feed) RequestSwitch(actor Actor, target, note string) PendingSwitch {
f.mu.Lock()
p := PendingSwitch{Target: target, Note: note, RequestedBy: actor, RequestedAt: time.Now()}
f.pending = &p
ev, subs := f.appendLocked(actor, "switch-requested", target, note)
f.mu.Unlock()
publish(subs, ev)
return p
}
// PendingSwitch returns the outstanding switch request, if any.
func (f *Feed) PendingSwitch() (PendingSwitch, bool) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.pending == nil {
return PendingSwitch{}, false
}
return *f.pending, true
}
// AckSwitch completes a pending switch: it makes the requested target the active
// project, clears the request, and records a "switch-completed" event carrying
// Claude's summary of where it left the previous project. Returns false if there
// was nothing pending.
func (f *Feed) AckSwitch(actor Actor, summary string) (PendingSwitch, bool) {
f.mu.Lock()
if f.pending == nil {
f.mu.Unlock()
return PendingSwitch{}, false
}
p := *f.pending
f.pending = nil
f.active = p.Target
ev, subs := f.appendLocked(actor, "switch-completed", p.Target, summary)
f.mu.Unlock()
publish(subs, ev)
return p, true
}
// CancelSwitch clears a pending switch (e.g. the user changed their mind).
func (f *Feed) CancelSwitch(actor Actor) (PendingSwitch, bool) {
f.mu.Lock()
if f.pending == nil {
f.mu.Unlock()
return PendingSwitch{}, false
}
p := *f.pending
f.pending = nil
ev, subs := f.appendLocked(actor, "switch-cancelled", p.Target, "")
f.mu.Unlock()
publish(subs, ev)
return p, true
}
// Record adds an arbitrary event to the feed.
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
f.mu.Lock()
ev, subs := f.appendLocked(actor, kind, repo, detail)
f.mu.Unlock()
publish(subs, ev)
return ev
}
// Events returns up to limit of the most recent events, oldest first. limit<=0
// returns all retained events.
func (f *Feed) Events(limit int) []Event {
f.mu.RLock()
defer f.mu.RUnlock()
if limit <= 0 || limit > len(f.events) {
limit = len(f.events)
}
out := make([]Event, limit)
copy(out, f.events[len(f.events)-limit:])
return out
}
// Subscribe returns a channel of future events and an unsubscribe func the
// caller MUST invoke when done (e.g. via defer) to avoid leaking the channel.
func (f *Feed) Subscribe() (<-chan Event, func()) {
ch := make(chan Event, 16)
f.mu.Lock()
f.subs[ch] = struct{}{}
f.mu.Unlock()
var once sync.Once
unsub := func() {
once.Do(func() {
f.mu.Lock()
delete(f.subs, ch)
f.mu.Unlock()
close(ch)
})
}
return ch, unsub
}
// appendLocked assigns id/time, appends (trimming to maxEvents), logs, and
// returns the event plus a snapshot of subscriber channels to publish to after
// the lock is released. Caller must hold f.mu.
func (f *Feed) appendLocked(actor Actor, kind, repo, detail string) (Event, []chan Event) {
ev := Event{ID: f.nextID, Time: time.Now(), Actor: actor, Kind: kind, Repo: repo, Detail: detail}
f.nextID++
f.events = append(f.events, ev)
if len(f.events) > f.maxEvents {
f.events = f.events[len(f.events)-f.maxEvents:]
}
if f.log != nil {
f.log.Info("activity", "actor", actor, "kind", kind, "repo", repo, "detail", detail)
}
subs := make([]chan Event, 0, len(f.subs))
for ch := range f.subs {
subs = append(subs, ch)
}
return ev, subs
}
// publish does a non-blocking send to each subscriber; a full channel (slow
// consumer) drops the event rather than stalling the producer.
func publish(subs []chan Event, ev Event) {
for _, ch := range subs {
select {
case ch <- ev:
default:
}
}
}
+17 -3
View File
@@ -14,7 +14,14 @@ import (
// Config is the typed application configuration.
type Config struct {
ListenAddr string // address the HTTP server binds to
ListenAddr string // address the plain HTTP server binds to
// TLS: when HTTPSAddr and both cert/key files are set, an HTTPS listener is
// started in addition to the HTTP one. Claude Desktop's MCP connector only
// accepts https:// URLs, so /mcp must be reachable over TLS (AGENT.md §8.1).
HTTPSAddr string // address the HTTPS server binds to ("" disables TLS)
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
TLSKeyFile string // PEM private key
RepoRoots []string // roots to scan for git repositories
GitBin string // path to the git binary
@@ -27,8 +34,10 @@ type Config struct {
Dev bool // readable console logging vs structured JSON
LogFile string // optional file to also append logs to
GitHubToken string // optional forge token (AGENT.md §8)
GitLabToken string // optional forge token (AGENT.md §8)
GiteaURL string // Gitea/Forgejo base URL (e.g. https://git.nilles.net)
GiteaToken string // Gitea token (read + PR write + branch delete) — §8.4
GitHubToken string // optional forge token (later provider)
GitLabToken string // optional forge token (later provider)
}
// Load reads .env (if present) then the environment, applying defaults.
@@ -38,6 +47,9 @@ func Load() (Config, error) {
c := Config{
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
HTTPSAddr: env("HTTPS_ADDR", ""),
TLSCertFile: env("TLS_CERT_FILE", ""),
TLSKeyFile: env("TLS_KEY_FILE", ""),
RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
GitBin: env("GIT_BIN", "git"),
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),
@@ -45,6 +57,8 @@ func Load() (Config, error) {
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
LogFile: env("LOG_FILE", ""),
GiteaURL: env("GITEA_URL", ""),
GiteaToken: env("GITEA_TOKEN", ""),
GitHubToken: env("GITHUB_TOKEN", ""),
GitLabToken: env("GITLAB_TOKEN", ""),
}
+99
View File
@@ -0,0 +1,99 @@
// Package forge is the provider-abstracted boundary to a git hosting service
// (AGENT.md §8.4). Gitea/Forgejo is the first provider; GitHub/GitLab can drop in
// behind the same interface. It is read + write: the write path powers
// "Merge & clean up" (merge a PR + delete its branch), which callers must confirm
// per §1.4. With no provider configured the features degrade gracefully.
package forge
import (
"context"
"errors"
"net/url"
"strings"
)
// Sentinel errors so callers (and the UI) can degrade gracefully.
var (
ErrNotConfigured = errors.New("forge integration not configured")
ErrNotSupported = errors.New("repository is not on a supported forge host")
)
// PullRequest is the provider-neutral view of an open PR/MR.
type PullRequest struct {
Number int64 `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
Head string `json:"head"` // head branch
Base string `json:"base"` // base branch
Draft bool `json:"draft"`
Mergeable bool `json:"mergeable"`
URL string `json:"url"`
SameRepo bool `json:"sameRepo"` // head & base in the same repo (branch is deletable)
}
// MergeMethod selects how a PR is merged.
type MergeMethod string
const (
MergeSquash MergeMethod = "squash"
MergeMerge MergeMethod = "merge"
MergeRebase MergeMethod = "rebase"
)
// MergeResult reports the outcome of a merge-and-cleanup.
type MergeResult struct {
Number int64 `json:"number"`
Merged bool `json:"merged"`
Branch string `json:"branch"`
BranchDeleted bool `json:"branchDeleted"`
}
// Provider talks to one hosting provider.
type Provider interface {
// Handles reports whether this provider serves the given remote host.
Handles(host string) bool
ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error)
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
}
// ParseRemote extracts (host, owner, repo) from a git remote URL, handling both
// https ("https://host/owner/repo.git") and scp-like ssh ("git@host:owner/repo.git").
func ParseRemote(remote string) (host, owner, repo string, ok bool) {
remote = strings.TrimSpace(remote)
if remote == "" {
return "", "", "", false
}
// scp-like ssh form has no scheme: user@host:path
if !strings.Contains(remote, "://") && strings.Contains(remote, "@") && strings.Contains(remote, ":") {
rest := remote[strings.Index(remote, "@")+1:]
colon := strings.Index(rest, ":")
host = rest[:colon]
owner, repo, ok = splitOwnerRepo(rest[colon+1:])
return host, owner, repo, ok
}
u, err := url.Parse(remote)
if err != nil || u.Hostname() == "" {
return "", "", "", false
}
owner, repo, ok = splitOwnerRepo(u.Path)
return u.Hostname(), owner, repo, ok
}
// splitOwnerRepo turns "/owner/repo.git" (or subgroups) into (owner, repo). It
// takes the last two path segments, which covers the common single-owner case.
func splitOwnerRepo(path string) (owner, repo string, ok bool) {
path = strings.Trim(path, "/")
path = strings.TrimSuffix(path, ".git")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return "", "", false
}
owner = parts[len(parts)-2]
repo = parts[len(parts)-1]
if owner == "" || repo == "" {
return "", "", false
}
return owner, repo, true
}
+31
View File
@@ -0,0 +1,31 @@
package forge
import "testing"
func TestParseRemote(t *testing.T) {
cases := []struct {
in string
host, owner, repo string
ok bool
}{
{"https://git.nilles.net/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"https://git.nilles.net/TBNilles/GitManager", "git.nilles.net", "TBNilles", "GitManager", true},
{"git@git.nilles.net:TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"https://git.nilles.net:3000/org/sub/Repo.git", "git.nilles.net", "sub", "Repo", true},
{"ssh://git@git.nilles.net:2222/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"not a url", "", "", "", false},
{"https://git.nilles.net/", "", "", "", false},
}
for _, c := range cases {
host, owner, repo, ok := ParseRemote(c.in)
if ok != c.ok {
t.Errorf("ParseRemote(%q) ok = %v, want %v", c.in, ok, c.ok)
continue
}
// Field values only matter on success.
if ok && (host != c.host || owner != c.owner || repo != c.repo) {
t.Errorf("ParseRemote(%q) = (%q,%q,%q), want (%q,%q,%q)",
c.in, host, owner, repo, c.host, c.owner, c.repo)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
package forge
import (
"context"
"fmt"
"net/url"
"strings"
"code.gitea.io/sdk/gitea"
)
// Gitea is a Provider backed by a Gitea/Forgejo instance. It also decides which
// repos it serves: only those whose remote host matches its base URL.
type Gitea struct {
host string
client *gitea.Client
}
// NewGitea builds a Gitea provider from a base URL and token. It returns
// (nil, nil) when not configured (either value empty) so forge features simply
// stay absent (§8.4 graceful degradation).
func NewGitea(baseURL, token string) (*Gitea, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
token = strings.TrimSpace(token)
if baseURL == "" || token == "" {
return nil, nil
}
u, err := url.Parse(baseURL)
if err != nil || u.Hostname() == "" {
return nil, fmt.Errorf("invalid GITEA_URL %q", baseURL)
}
c, err := gitea.NewClient(baseURL, gitea.SetToken(token))
if err != nil {
return nil, err
}
return &Gitea{host: u.Hostname(), client: c}, nil
}
// Handles reports whether a remote host is this Gitea instance.
func (g *Gitea) Handles(host string) bool {
return strings.EqualFold(host, g.host)
}
// ListPullRequests lists the open PRs of owner/repo.
func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullRequest, error) {
prs, _, err := g.client.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{State: gitea.StateOpen})
if err != nil {
return nil, err
}
out := make([]PullRequest, 0, len(prs))
for _, p := range prs {
out = append(out, toPR(p))
}
return out, nil
}
// MergeAndCleanup merges the PR and deletes its head branch (when the head is in
// the same repo — never a fork's branch). Callers MUST have confirmed with the
// user first (§1.4).
func (g *Gitea) MergeAndCleanup(_ context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error) {
pr, _, err := g.client.GetPullRequest(owner, repo, number)
if err != nil {
return MergeResult{}, err
}
branch := ""
if pr.Head != nil {
branch = pr.Head.Ref
}
sameRepo := pr.Head != nil && pr.Base != nil && pr.Head.RepoID == pr.Base.RepoID
del := sameRepo && branch != ""
merged, _, err := g.client.MergePullRequest(owner, repo, number, gitea.MergePullRequestOption{
Style: toStyle(method),
DeleteBranchAfterMerge: &del,
})
if err != nil {
return MergeResult{}, err
}
res := MergeResult{Number: number, Merged: merged, Branch: branch, BranchDeleted: del && merged}
// Best-effort fallback in case the merge option didn't delete the branch
// (older Gitea). Ignore the error — the branch may already be gone.
if merged && del {
_, _, _ = g.client.DeleteRepoBranch(owner, repo, branch)
}
return res, nil
}
func toPR(p *gitea.PullRequest) PullRequest {
pr := PullRequest{
Number: p.Index,
Title: p.Title,
Draft: p.Draft,
Mergeable: p.Mergeable,
URL: p.HTMLURL,
}
if p.Poster != nil {
pr.Author = p.Poster.UserName
}
if p.Head != nil {
pr.Head = p.Head.Ref
}
if p.Base != nil {
pr.Base = p.Base.Ref
}
if p.Head != nil && p.Base != nil {
pr.SameRepo = p.Head.RepoID == p.Base.RepoID
}
return pr
}
func toStyle(m MergeMethod) gitea.MergeStyle {
switch m {
case MergeMerge:
return gitea.MergeStyleMerge
case MergeRebase:
return gitea.MergeStyleRebase
default:
return gitea.MergeStyleSquash
}
}
+28
View File
@@ -120,6 +120,34 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error {
return err
}
// --- Mutating operations (user/Claude-initiated only; §1.3, §1.4) -----------
// Pull integrates the upstream branch (fetch + merge/ff per repo config).
func (c *CLI) Pull(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "pull")
}
// Push publishes the current branch to its upstream. Plain push only — never a
// forced push here (that is a §1.4 action to be added deliberately if ever).
func (c *CLI) Push(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "push")
}
// Commit stages every change and commits it with message.
func (c *CLI) Commit(ctx context.Context, dir, message string) (string, error) {
if _, err := c.run(ctx, dir, "add", "-A"); err != nil {
return "", err
}
return c.run(ctx, dir, "commit", "-m", message)
}
// DiscardAll hard-resets tracked files to HEAD, throwing away uncommitted
// changes. DESTRUCTIVE (§1.4): callers MUST confirm with the user first.
// Untracked files are left in place.
func (c *CLI) DiscardAll(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "reset", "--hard", "HEAD")
}
// Branch is a local branch and its upstream, if any.
type Branch struct {
Name string `json:"name"`
+266
View File
@@ -0,0 +1,266 @@
// Package mcp exposes GitManager's capabilities to Claude as an MCP server over
// Streamable HTTP (AGENT.md §8.1). The tool handlers are THIN ADAPTERS over the
// shared service layer (§1.7) — no Git/forge logic lives here. Read tools only
// for now; acting/destructive tools arrive with the service methods that back
// them, carrying the §1.4 confirmation contract.
package mcp
import (
"context"
"fmt"
"net/http"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
// getRepoInput is the argument schema for the get_repo tool.
type getRepoInput struct {
Path string `json:"path" jsonschema:"absolute filesystem path of the repository, exactly as returned by list_repos"`
}
// listReposOutput wraps the repository list. MCP structured output must be a JSON
// object, so the SDK-inferred outputSchema has to be type "object" — returning a
// bare slice yields type "array", which Claude Desktop rejects at tools/list.
type listReposOutput struct {
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
}
// setActiveProjectInput is the argument schema for set_active_project.
type setActiveProjectInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository to make active, exactly as returned by list_repos"`
}
// activeProjectOutput reports the active project path (object, per the rule above).
type activeProjectOutput struct {
Path string `json:"path" jsonschema:"absolute path of the active project, empty when none is set"`
}
// activityOutput wraps the activity feed (object, per the rule above).
type activityOutput struct {
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
}
// pendingSwitchOutput reports whether the user has asked Claude to switch projects.
type pendingSwitchOutput struct {
Pending bool `json:"pending" jsonschema:"true if the user has requested a switch you should complete"`
Target string `json:"target,omitempty" jsonschema:"the repository path to switch to"`
Note string `json:"note,omitempty" jsonschema:"an optional note from the user"`
RequestedAt time.Time `json:"requestedAt,omitempty"`
}
// ackSwitchInput is the argument schema for ack_switch.
type ackSwitchInput struct {
Summary string `json:"summary" jsonschema:"a short note on where you left the previous project (shown to the user)"`
}
// ackSwitchOutput reports the completed switch.
type ackSwitchOutput struct {
Switched bool `json:"switched"`
Target string `json:"target,omitempty" jsonschema:"the repository now active"`
}
// repoPathInput selects a repository by path (from list_repos).
type repoPathInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, exactly as returned by list_repos"`
}
// prListOutput wraps the pull requests (object, per the schema rule).
type prListOutput struct {
PRs []forge.PullRequest `json:"prs" jsonschema:"open pull requests for the repository"`
}
// mergePRInput selects the PR to merge and clean up.
type mergePRInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"`
}
// gitCommitInput is the argument schema for git_commit.
type gitCommitInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Message string `json:"message" jsonschema:"the commit message"`
}
// gitActionOutput carries a git command's output (object, per the schema rule).
type gitActionOutput struct {
Output string `json:"output" jsonschema:"the git command output (may be empty)"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
Name: "gitmanager",
Title: "GitManager",
Version: version,
Description: "Discover and inspect the user's local Git repositories.",
}, nil)
// list_repos — no arguments (empty struct = object schema with no properties).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "list_repos",
Description: "List every Git repository GitManager has discovered, each with its current branch, dirty/clean state, ahead/behind counts, and remote names.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listReposOutput, error) {
return nil, listReposOutput{Repos: svc.ListRepos()}, nil
})
// get_repo — details for one already-discovered repository.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_repo",
Description: "Get details for one repository: its local branches (with upstreams), recent commits, and remote URLs. The path must be one returned by list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRepoInput) (*mcpsdk.CallToolResult, repos.Detail, error) {
detail, ok := svc.RepoDetail(ctx, in.Path)
if !ok {
return nil, repos.Detail{}, fmt.Errorf("unknown repository %q — call list_repos for valid paths", in.Path)
}
return nil, detail, nil
})
// get_active_project — the repo/task currently in focus (§8.2).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_active_project",
Description: "Get the active project — the repository the user is currently focused on. Check this to stay in sync with the user; path is empty when none is set.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
})
// set_active_project — Claude switches the focus to another repo.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "set_active_project",
Description: "Set the active project to the given repository path (from list_repos). Use this when switching which repository you are working in so the app and user stay in sync.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in setActiveProjectInput) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
if _, _, err := svc.SetActiveProject(activity.ActorClaude, in.Path); err != nil {
return nil, activeProjectOutput{}, fmt.Errorf("%w — call list_repos for valid paths", err)
}
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
})
// get_activity — recent user + Claude actions, so Claude can catch up.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_activity",
Description: "Get the recent activity feed (user and Claude actions, oldest first): repo selections, active-project changes, and more as features land. Use it to see what the user has done since you last looked.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activityOutput, error) {
return nil, activityOutput{Events: svc.Activity(50)}, nil
})
// get_pending_switch — has the user asked you to switch projects? (§8.3)
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_pending_switch",
Description: "Check whether the user has asked you to switch to a different project. If pending is true, finish your current work to a SAFE stopping point (commit or stash so nothing is lost), then call ack_switch to complete the handoff.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, pendingSwitchOutput, error) {
p, ok := svc.PendingSwitch()
if !ok {
return nil, pendingSwitchOutput{Pending: false}, nil
}
return nil, pendingSwitchOutput{Pending: true, Target: p.Target, Note: p.Note, RequestedAt: p.RequestedAt}, nil
})
// ack_switch — complete a pending handoff and tell the user where you left off.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "ack_switch",
Description: "Complete a pending project switch: makes the requested target the active project and clears the request. Call this only after reaching a safe stopping point in the current project. Pass a short summary of where you left it — the user is notified.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in ackSwitchInput) (*mcpsdk.CallToolResult, ackSwitchOutput, error) {
p, ok := svc.AckSwitch(in.Summary)
if !ok {
return nil, ackSwitchOutput{Switched: false}, fmt.Errorf("no pending switch to acknowledge")
}
return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil
})
// list_prs — open pull requests for a repo (§8.4).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "list_prs",
Description: "List the open pull requests for a repository (needs a configured forge such as Gitea). The path must be one returned by list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, prListOutput, error) {
prs, err := svc.ForgePRs(ctx, in.Path)
if err != nil {
return nil, prListOutput{}, err
}
return nil, prListOutput{PRs: prs}, nil
})
// merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "merge_and_cleanup_pr",
Description: "Merge a pull request (squash) AND delete its source branch — the \"Merge & clean up\" action. This is irreversible: confirm the exact PR number and repository with the user BEFORE calling. Merged history remains on the host; only the branch is removed.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRInput) (*mcpsdk.CallToolResult, forge.MergeResult, error) {
res, err := svc.MergeAndCleanup(ctx, activity.ActorClaude, in.Path, in.Number)
if err != nil {
return nil, forge.MergeResult{}, err
}
return nil, res, nil
})
// --- Git commands (the same ops as the right-click menu, §6/§1.7) --------
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_fetch",
Description: "Fetch updates from the remote for a repository (does not change the working tree). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitFetch(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_pull",
Description: "Pull the latest changes (fetch + merge) into a repository's current branch. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitPull(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_push",
Description: "Push the current branch to its upstream (plain push, never forced). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitPush(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_commit",
Description: "Stage all changes and commit them with a message. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCommitInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitCommit(ctx, activity.ActorClaude, in.Path, in.Message)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_discard_changes",
Description: "DESTRUCTIVE: discard ALL uncommitted changes to tracked files (git reset --hard HEAD). This cannot be undone — confirm the exact repository with the user BEFORE calling. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitDiscard(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
return s
}
// Handler serves the MCP server over Streamable HTTP. Mount it at /mcp. Like the
// rest of the app it is localhost-bound and unauthenticated (§8.1) — the same
// server instance backs every session.
func Handler(s *mcpsdk.Server) http.Handler {
return mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server {
return s
}, nil)
}
+242
View File
@@ -0,0 +1,242 @@
package mcp
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/git"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
// service -> MCP tools, called by an in-memory MCP client.
func TestMCPRoundTrip(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "myrepo")
if err := os.Mkdir(repoPath, 0o755); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test")
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "add", "-A")
runGit(t, repoPath, "commit", "-m", "first commit")
// Populate the index via the real scanner, then build service + MCP server.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
g := git.New("git")
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background())
svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo)
srv := NewServer(svc, "test")
// Wire an in-memory client<->server session.
ctx := context.Background()
clientT, serverT := mcpsdk.NewInMemoryTransports()
serverSession, err := srv.Connect(ctx, serverT, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer serverSession.Close()
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0"}, nil)
cs, err := client.Connect(ctx, clientT, nil)
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
// list_repos should find our one repo.
res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "list_repos"})
if err != nil {
t.Fatalf("list_repos: %v", err)
}
var listOut listReposOutput
decodeResult(t, res, &listOut)
states := listOut.Repos
if len(states) != 1 {
t.Fatalf("expected 1 repo, got %d: %+v", len(states), states)
}
if states[0].Name != "myrepo" || states[0].Branch != "main" {
t.Fatalf("unexpected repo state: %+v", states[0])
}
// get_repo should return detail including the commit we made.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "get_repo",
Arguments: map[string]any{"path": states[0].Path},
})
if err != nil {
t.Fatalf("get_repo: %v", err)
}
var detail repos.Detail
decodeResult(t, res, &detail)
if len(detail.Commits) != 1 || detail.Commits[0].Subject != "first commit" {
t.Fatalf("unexpected detail commits: %+v", detail.Commits)
}
// get_repo with a bad path is a tool error, not a protocol error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "get_repo",
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
})
if err != nil {
t.Fatalf("get_repo(bad) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError for unknown repo, got success")
}
// active project starts empty.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
if err != nil {
t.Fatalf("get_active_project: %v", err)
}
var ap struct {
Path string `json:"path"`
}
decodeResult(t, res, &ap)
if ap.Path != "" {
t.Fatalf("expected empty active project, got %q", ap.Path)
}
// set_active_project to our repo, then read it back.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "set_active_project",
Arguments: map[string]any{"path": states[0].Path},
})
if err != nil {
t.Fatalf("set_active_project: %v", err)
}
if res.IsError {
t.Fatalf("set_active_project returned tool error: %+v", res.Content)
}
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
decodeResult(t, res, &ap)
if ap.Path != states[0].Path {
t.Fatalf("active project = %q, want %q", ap.Path, states[0].Path)
}
// setting an unknown project is a tool error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "set_active_project",
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
})
if err != nil {
t.Fatalf("set_active_project(bad) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError for unknown active project")
}
// the activity feed should now contain the active-project-changed event.
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_activity"})
var act struct {
Events []activity.Event `json:"events"`
}
decodeResult(t, res, &act)
found := false
for _, ev := range act.Events {
if ev.Kind == "active-project-changed" && ev.Repo == states[0].Path && ev.Actor == activity.ActorClaude {
found = true
}
}
if !found {
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
}
// --- graceful handoff (§8.3): user requests, Claude acks -----------------
svc.RequestSwitch(activity.ActorUser, states[0].Path, "fix a bug")
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
var ps struct {
Pending bool `json:"pending"`
Target string `json:"target"`
}
decodeResult(t, res, &ps)
if !ps.Pending || ps.Target != states[0].Path {
t.Fatalf("expected pending switch to %q, got %+v", states[0].Path, ps)
}
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "ack_switch",
Arguments: map[string]any{"summary": "left tests green"},
})
if err != nil {
t.Fatalf("ack_switch: %v", err)
}
if res.IsError {
t.Fatalf("ack_switch tool error: %+v", res.Content)
}
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
decodeResult(t, res, &ps)
if ps.Pending {
t.Fatalf("expected no pending switch after ack, got %+v", ps)
}
// ack with nothing pending is a tool error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "ack_switch",
Arguments: map[string]any{"summary": "nothing"},
})
if err != nil {
t.Fatalf("ack_switch(empty) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError acking with no pending switch")
}
// --- git_commit via MCP (adapter wiring) --------------------------------
if err := os.WriteFile(filepath.Join(repoPath, "extra.txt"), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "git_commit",
Arguments: map[string]any{"path": repoPath, "message": "add extra via mcp"},
})
if err != nil {
t.Fatalf("git_commit: %v", err)
}
if res.IsError {
t.Fatalf("git_commit tool error: %+v", res.Content)
}
}
// decodeResult unmarshals the JSON text content of a tool result into v.
func decodeResult(t *testing.T, res *mcpsdk.CallToolResult, v any) {
t.Helper()
for _, c := range res.Content {
if tc, ok := c.(*mcpsdk.TextContent); ok {
if err := json.Unmarshal([]byte(tc.Text), v); err != nil {
t.Fatalf("unmarshal result: %v (text=%s)", err, tc.Text)
}
return
}
}
t.Fatalf("no text content in result: %+v", res.Content)
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
+7
View File
@@ -122,6 +122,13 @@ func (s *Scanner) Refresh(ctx context.Context) {
}
}
// RefreshRepo re-scans a single repository and updates the index. Used after a
// mutating action so the UI reflects the new state without waiting for the next
// full scan.
func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
s.Index.set(s.refreshOne(ctx, path))
}
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
+252
View File
@@ -0,0 +1,252 @@
// Package service is the ONE capability layer behind both the HTTP API and the
// MCP server (AGENT.md §1.7). HTTP handlers and MCP tool handlers are thin
// adapters that call these methods; Git/forge logic never lives in a handler.
// Everything here goes through the internal/git (and later internal/forge)
// boundaries and obeys the safety rules (§1.4).
package service
import (
"context"
"fmt"
"path/filepath"
"strings"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
// Service holds the shared dependencies the capabilities need.
type Service struct {
git *git.CLI
index *repos.Index
feed *activity.Feed
forge *forge.Gitea // nil when no forge is configured
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
}
// New builds a Service over the git boundary, the scanner's repo index, the
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
// (may be nil) used to re-scan a repo after a mutating action.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea, refresh func(context.Context, string)) *Service {
return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh}
}
// ListRepos returns a snapshot of every discovered repository.
func (s *Service) ListRepos() []repos.State {
return s.index.List()
}
// GetRepo returns the cached state for one repo, or false if it is not indexed.
// The path is cleaned so separator style does not defeat the exact-match lookup.
func (s *Service) GetRepo(path string) (repos.State, bool) {
return s.index.Get(filepath.Clean(path))
}
// RepoDetail returns the enriched detail (branches, commits, remotes) for one
// repo. The second return is false when the path is not an indexed repository —
// we never run git against an arbitrary caller-supplied path (§1.3).
func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bool) {
base, ok := s.index.Get(filepath.Clean(path))
if !ok {
return repos.Detail{}, false
}
return repos.BuildDetail(ctx, s.git, base), true
}
// --- Activity & active project (§8.2) --------------------------------------
// ActiveProject returns the current active project path ("" if none).
func (s *Service) ActiveProject() string {
return s.feed.ActiveProject()
}
// SetActiveProject makes path the active project (or clears it when empty). It
// rejects a path that is not an indexed repository — the active project must be
// a real repo (§1.3). Returns the recorded event and whether it changed.
func (s *Service) SetActiveProject(actor activity.Actor, path string) (activity.Event, bool, error) {
if path != "" {
path = filepath.Clean(path)
if _, ok := s.index.Get(path); !ok {
return activity.Event{}, false, fmt.Errorf("unknown repository %q", path)
}
}
ev, changed := s.feed.SetActiveProject(actor, path)
return ev, changed, nil
}
// RecordActivity appends an arbitrary event to the feed.
func (s *Service) RecordActivity(actor activity.Actor, kind, repo, detail string) activity.Event {
return s.feed.Record(actor, kind, repo, detail)
}
// Activity returns up to limit recent events, oldest first.
func (s *Service) Activity(limit int) []activity.Event {
return s.feed.Events(limit)
}
// SubscribeActivity returns a channel of future events plus an unsubscribe func
// the caller must invoke when done.
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
return s.feed.Subscribe()
}
// --- Graceful project handoff (§8.3) ---------------------------------------
// RequestSwitch records a user's request for Claude to switch to target. The
// target must be an indexed repository.
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
target = filepath.Clean(target)
if _, ok := s.index.Get(target); !ok {
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
}
return s.feed.RequestSwitch(actor, target, note), nil
}
// PendingSwitch returns the outstanding switch request, if any.
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
return s.feed.PendingSwitch()
}
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
// project to the requested target and records Claude's summary. Returns false if
// nothing was pending.
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
return s.feed.AckSwitch(activity.ActorClaude, summary)
}
// CancelSwitch clears a pending switch request.
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
return s.feed.CancelSwitch(actor)
}
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
// ForgeConfigured reports whether any forge provider is set up.
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
// when no provider is set, or forge.ErrNotSupported when the repo's remote is not
// on the configured host.
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return nil, err
}
return s.forge.ListPullRequests(ctx, owner, repo)
}
// MergeAndCleanup merges a PR and deletes its branch, then records the action.
// The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude).
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.MergeResult{}, err
}
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
if err != nil {
return forge.MergeResult{}, err
}
detail := fmt.Sprintf("merged PR #%d", res.Number)
if res.BranchDeleted && res.Branch != "" {
detail += fmt.Sprintf(", deleted branch %s", res.Branch)
}
s.feed.Record(actor, "pr-merged", filepath.Clean(repoPath), detail)
return res, nil
}
// --- Git actions (the plain-language commands, §6) --------------------------
// gitAction runs one mutating git op through the boundary, records the outcome
// on the activity feed, and refreshes the repo in the index on success. Callers
// are responsible for §1.4 confirmation of destructive ops (e.g. discard).
func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind string, run func(dir string) (string, error)) (string, error) {
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", fmt.Errorf("unknown repository %q", repoPath)
}
out, err := run(base.Path)
if err != nil {
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
return "", err
}
s.feed.Record(actor, kind, base.Path, "ok")
if s.refresh != nil {
s.refresh(ctx, base.Path)
}
return out, nil
}
// GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands
// the right-click menu (and, later, MCP) invoke.
func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-fetch", func(d string) (string, error) {
return "", s.git.Fetch(ctx, d)
})
}
func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-pull", func(d string) (string, error) {
return s.git.Pull(ctx, d)
})
}
func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-push", func(d string) (string, error) {
return s.git.Push(ctx, d)
})
}
func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath, message string) (string, error) {
if strings.TrimSpace(message) == "" {
return "", fmt.Errorf("a commit message is required")
}
return s.gitAction(ctx, actor, repoPath, "git-commit", func(d string) (string, error) {
return s.git.Commit(ctx, d, message)
})
}
// GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first.
func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-discard", func(d string) (string, error) {
return s.git.DiscardAll(ctx, d)
})
}
// resolveForge maps a repo path to (owner, repo) on the configured forge host via
// its git remotes, preferring "origin".
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
if s.forge == nil {
return "", "", forge.ErrNotConfigured
}
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", "", fmt.Errorf("unknown repository %q", repoPath)
}
remotes, err := s.git.RemoteDetails(ctx, base.Path)
if err != nil {
return "", "", err
}
// Prefer origin, then any matching remote.
var fallback [2]string
haveFallback := false
for _, rm := range remotes {
host, o, r, ok := forge.ParseRemote(rm.URL)
if !ok || !s.forge.Handles(host) {
continue
}
if rm.Name == "origin" {
return o, r, nil
}
if !haveFallback {
fallback = [2]string{o, r}
haveFallback = true
}
}
if haveFallback {
return fallback[0], fallback[1], nil
}
return "", "", forge.ErrNotSupported
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"context"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gitmanager/internal/activity"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway
// temp repo — never a real one.
func TestGitActions(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "r")
mustMkdir(t, repoPath)
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "t@e.com")
runGit(t, repoPath, "config", "user.name", "T")
writeFile(t, filepath.Join(repoPath, "a.txt"), "one\n")
runGit(t, repoPath, "add", "-A")
runGit(t, repoPath, "commit", "-m", "init")
log := slog.New(slog.NewTextHandler(io.Discard, nil))
g := git.New("git")
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background())
feed := activity.New(log, 200)
svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo)
ctx := context.Background()
// Commit a new file, then the repo should be clean in the index.
writeFile(t, filepath.Join(repoPath, "b.txt"), "two\n")
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, "add b"); err != nil {
t.Fatalf("GitCommit: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Dirty {
t.Fatalf("expected clean repo after commit, got dirty")
}
// Commit with a blank message is rejected.
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, " "); err == nil {
t.Fatalf("expected error committing with blank message")
}
// Modify a tracked file, then discard resets it. (We check the file itself
// rather than index dirtiness, since the index only updates on a refresh.)
writeFile(t, filepath.Join(repoPath, "a.txt"), "CHANGED\n")
if _, err := svc.GitDiscard(ctx, activity.ActorUser, repoPath); err != nil {
t.Fatalf("GitDiscard: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Dirty {
t.Fatalf("expected clean repo after discard")
}
// Trim to ignore autocrlf line-ending normalization on Windows.
if got := strings.TrimSpace(readFile(t, filepath.Join(repoPath, "a.txt"))); got != "one" {
t.Fatalf("a.txt = %q, want restored to \"one\"", got)
}
// The feed recorded the successful actions.
kinds := map[string]bool{}
for _, e := range feed.Events(0) {
if e.Detail == "ok" {
kinds[e.Kind] = true
}
}
if !kinds["git-commit"] || !kinds["git-discard"] {
t.Fatalf("expected git-commit and git-discard ok events, got %v", kinds)
}
}
func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.Mkdir(p, 0o755); err != nil {
t.Fatal(err)
}
}
func writeFile(t *testing.T, p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
+54
View File
@@ -45,6 +45,27 @@
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
</ul>
<h2>Right-click commands</h2>
<p>
Right-click any repository for a menu of plain-language commands — no git
knowledge needed:
</p>
<ul>
<li><strong>Get latest</strong> — pull the newest changes.</li>
<li><strong>Publish</strong> — push your commits.</li>
<li><strong>Check for updates</strong> — fetch without changing your files.</li>
<li><strong>Save my work…</strong> — commit everything (asks for a message).</li>
<li><strong>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
<li><strong>Copy path</strong>.</li>
<li><strong>Discard all changes…</strong> — throw away uncommitted edits
(asks you to confirm; can't be undone).</li>
</ul>
<p>
What each command did shows up in the activity panel. (Get latest / Publish /
Check for updates need your server to have git credentials; until then they'll
report a sign-in error.)
</p>
<h2>Repository details</h2>
<p>
Click any repository in the list to open its details on the right: its
@@ -53,6 +74,39 @@
most recent commits.
</p>
<h2>Active project &amp; activity</h2>
<p>
Clicking a repository also makes it your <strong>active project</strong>
the one you're currently focused on — shown in the activity panel at the
bottom. That panel also lists recent actions by both you and Claude, updating
live. When GitManager is connected to Claude, Claude can see your active
project and this activity, so you stay on the same page.
</p>
<h2>Pull requests: Merge &amp; clean up</h2>
<p>
When a repository is hosted on your Gitea server, its open pull requests
appear under the details panel. Each has a <strong>Merge &amp; clean up</strong>
button: it squash-merges the pull request and <strong>deletes its
branch</strong> in one step, so finished work doesn't leave branches lying
around. You'll be asked to confirm — it names the pull request and the branch
that will be deleted — because it can't be undone (the merged history stays
on the server; only the branch is removed). If you don't use a Gitea server,
this section stays hidden.
</p>
<h2>Handing a project off to Claude</h2>
<p>
When GitManager is connected to Claude, you can hand the current project
off. Select the repository (that makes it your active project), then click
<strong>"Ask Claude to switch to …"</strong> in the handoff bar. Claude
won't drop what it's doing — it finishes to a safe stopping point (saving
any in-progress work) and then switches. You'll see a "waiting…" message
while it wraps up, and a confirmation with a short note of where it left the
previous project once it has switched. You can <strong>Cancel</strong> a
pending request any time before Claude completes it.
</p>
<h2>Background refresh</h2>
<p>
The dashboard refreshes on its own. By default it does <em>not</em> reach
+18 -5
View File
@@ -9,6 +9,10 @@
(AGENT.md §1.1). Each fetches its own data on connect. -->
<script type="module" src="/components/repo-list/repo-list.js"></script>
<script type="module" src="/components/repo-detail/repo-detail.js"></script>
<script type="module" src="/components/activity-feed/activity-feed.js"></script>
<script type="module" src="/components/handoff-bar/handoff-bar.js"></script>
<script type="module" src="/components/pr-list/pr-list.js"></script>
<script type="module" src="/components/repo-menu/repo-menu.js"></script>
<style>
header {
display: flex;
@@ -22,14 +26,15 @@
header nav { margin-left: auto; }
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4).
A real dockable layout is layered on later; this is the static default. */
main {
main { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.cols {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 16px;
padding: 20px;
align-items: start;
}
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
.right { display: grid; gap: 16px; }
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
</style>
</head>
<body>
@@ -39,8 +44,16 @@
<nav><a href="/help">Help</a></nav>
</header>
<main>
<repo-list></repo-list>
<repo-detail></repo-detail>
<handoff-bar></handoff-bar>
<div class="cols">
<repo-list></repo-list>
<div class="right">
<repo-detail></repo-detail>
<pr-list hidden></pr-list>
</div>
</div>
<activity-feed></activity-feed>
</main>
<repo-menu></repo-menu>
</body>
</html>