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
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>
84 lines
3.4 KiB
Python
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)
|