Files
FabledGrimoire/src/grimoire/mcp/tools/repos.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

84 lines
3.4 KiB
Python

"""Repo -> project binding MCP tools.
Let the operator map the working git repository to a Grimoire project so the
SessionStart hook auto-loads that project's context — without pinning a project
id in plugin config (which would force a single project for every repo). Run
`bind_repo` once per repo; the binding is keyed on the normalized git remote, so
it survives re-clones and ssh/https URL differences.
"""
from __future__ import annotations
from grimoire.mcp._context import current_user_id
from grimoire.services import projects as projects_svc
from grimoire.services import repo_bindings as repo_bindings_svc
async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict:
"""Bind a git repository to a Grimoire project for session-start context.
After this, any session started in that repo auto-loads the project's
context (the SessionStart hook sends the repo's remote; the server resolves
it here). Idempotent — re-binding the same repo updates the target project.
The binding is also what the shape ledger reads (refresh_pattern_coverage):
`ref` names the branch it follows. Default (""): the repo's default branch
— which means the ledger only sees work after a merge. A project that
works on a dev branch should bind with ref="dev" so classification follows
the push, not the merge. Re-binding with ref="" keeps the standing ref;
pass ref="-" to clear it back to the default branch.
Args:
repo_url: the repo's git remote (e.g. the output of
`git remote get-url origin` — ssh or https form, both work).
project_id: the Grimoire project this repo represents.
ref: branch the ledger follows ("" = leave as is / default branch on
a new binding; "-" = clear to the default branch).
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
ref_arg = None if not ref else ("" if ref.strip() == "-" else ref)
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id, ref_arg)
follows = binding.ref or "the default branch"
return {
"repo_key": binding.repo_key,
"project_id": binding.project_id,
"project_title": project.title,
"ref": binding.ref,
"message": (
f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}); "
f"the ledger follows {follows}."
),
}
async def list_repo_bindings() -> dict:
"""List every repo -> project binding for the current user."""
uid = current_user_id()
bindings = await repo_bindings_svc.list_bindings(uid)
return {"bindings": [b.to_dict() for b in bindings]}
async def unbind_repo(repo_url: str) -> dict:
"""Remove a repo's binding. Sessions there fall back to standing rules only.
Args:
repo_url: the repo's git remote (same form used to bind it).
"""
uid = current_user_id()
removed = await repo_bindings_svc.delete_binding(uid, repo_url)
key = repo_bindings_svc.normalize_repo_key(repo_url)
return {
"removed": removed,
"repo_key": key,
"message": (
f"Unbound `{key}`." if removed else f"No binding found for `{key}`."
),
}
def register(mcp) -> None:
for fn in (bind_repo, list_repo_bindings, unbind_repo):
mcp.tool(name=fn.__name__)(fn)