diff --git a/alembic/versions/0064_repo_bindings.py b/alembic/versions/0064_repo_bindings.py new file mode 100644 index 0000000..bb4c609 --- /dev/null +++ b/alembic/versions/0064_repo_bindings.py @@ -0,0 +1,58 @@ +"""repo -> project bindings + +Revision ID: 0064 +Revises: 0063 +Create Date: 2026-06-10 + +Maps a git repository (by its normalized remote, `repo_key`) to the Scribe +project it represents, so the SessionStart hook can resolve the active project +from the working repo instead of a project id pinned in plugin config. FKs +CASCADE so deleting a user or project removes the stale binding automatically. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0064" +down_revision = "0063" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "repo_bindings", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column( + "user_id", + sa.BigInteger(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "project_id", + sa.BigInteger(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("repo_key", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.UniqueConstraint("user_id", "repo_key", name="uq_repo_bindings_user_repo"), + ) + op.create_index("ix_repo_bindings_user_id", "repo_bindings", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_repo_bindings_user_id", table_name="repo_bindings") + op.drop_table("repo_bindings") diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 4240356..2607a32 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe second brain for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, and a set of universal process-skills (brainstorm, debug, TDD, plan, verify). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.2", + "version": "0.1.3", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { @@ -21,11 +21,6 @@ "title": "Scribe API key", "description": "An fmcp_ API key from Settings → API Keys (read scope is enough for the session-start hook; write scope to use the tools)", "sensitive": true - }, - "project_id": { - "type": "string", - "title": "Active project id (optional)", - "description": "Numeric Scribe project id to scope the session-start context. Leave blank to inject standing rules only." } } } diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index 4ed77bb..772c4ba 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -7,14 +7,18 @@ # CLAUDE_PLUGIN_OPTION_ env vars: # CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash # CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive) -# CLAUDE_PLUGIN_OPTION_project_id optional numeric project id # -# IMPORTANT: do NOT pass these via `${user_config.*}` substitution in +# The active project is NOT configured here — it's resolved server-side from +# the working repo's git remote (see services/repo_bindings). This keeps a +# single install working across many repos/projects: bind each repo once with +# the `bind_repo` MCP tool. An unbound repo just yields standing rules + a hint. +# +# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in # hooks.json — sensitive userConfig values (api_token) are kept in the keychain # and are never spliced into a hook command line, so the placeholder arrives # unexpanded. The harness env vars above are the supported channel. SCRIBE_URL -# / SCRIBE_TOKEN / SCRIBE_PROJECT_ID still override, for the settings.json -# dogfooding path where the hook is wired up by hand. +# / SCRIBE_TOKEN still override, for the settings.json dogfooding path where the +# hook is wired up by hand. # # FAIL-OPEN: any missing tool/config, network error, or unreachable instance # injects nothing and exits 0. A session must never be blocked by Scribe. @@ -25,18 +29,25 @@ command -v curl >/dev/null 2>&1 || exit 0 url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}} token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} -project_id=${SCRIBE_PROJECT_ID:-${CLAUDE_PLUGIN_OPTION_project_id:-}} # Guard against an unexpanded `${...}` placeholder reaching us as a literal — it # would otherwise be sent as a garbage Bearer token and 401. Treat as unset. case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac -case "$project_id" in *'${'*) project_id="" ;; esac [ -n "$url" ] && [ -n "$token" ] || exit 0 +# Resolve the working repo's remote so the server can map it to a project. Use +# the session's project dir when provided, else the current dir. No remote (or +# not a git repo) → omit; the server returns standing rules only. +repo_dir=${CLAUDE_PROJECT_DIR:-$PWD} +repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) + q="" -[ -n "$project_id" ] && q="?project_id=${project_id}" +if [ -n "$repo" ]; then + enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" + [ -n "$enc" ] && q="?repo=${enc}" +fi body=$(curl -fsS --max-time 8 \ -H "Authorization: Bearer ${token}" \ diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py index 418904f..934cf4c 100644 --- a/src/scribe/mcp/tools/__init__.py +++ b/src/scribe/mcp/tools/__init__.py @@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called from `mcp.server.build_mcp_server`. """ from scribe.mcp.tools import ( - entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash, + entities, events, milestones, notes, processes, projects, recent, repos, rulebooks, search, tags, tasks, trash, ) @@ -20,6 +20,7 @@ def register_all(mcp) -> None: tags.register(mcp) recent.register(mcp) entities.register(mcp) + repos.register(mcp) processes.register(mcp) rulebooks.register(mcp) trash.register(mcp) diff --git a/src/scribe/mcp/tools/repos.py b/src/scribe/mcp/tools/repos.py new file mode 100644 index 0000000..f652587 --- /dev/null +++ b/src/scribe/mcp/tools/repos.py @@ -0,0 +1,68 @@ +"""Repo -> project binding MCP tools. + +Let the operator map the working git repository to a Scribe 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 scribe.mcp._context import current_user_id +from scribe.services import projects as projects_svc +from scribe.services import repo_bindings as repo_bindings_svc + + +async def bind_repo(repo_url: str, project_id: int) -> dict: + """Bind a git repository to a Scribe 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. + + 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 Scribe project this repo represents. + """ + 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") + binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id) + return { + "repo_key": binding.repo_key, + "project_id": binding.project_id, + "project_title": project.title, + "message": f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}).", + } + + +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) diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index a012b55..c99959f 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -40,3 +40,4 @@ from scribe.models.user_profile import UserProfile # noqa: E402, F401 from scribe.models.rulebook import ( # noqa: E402, F401 Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions, ) +from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 diff --git a/src/scribe/models/repo_binding.py b/src/scribe/models/repo_binding.py new file mode 100644 index 0000000..b5e8039 --- /dev/null +++ b/src/scribe/models/repo_binding.py @@ -0,0 +1,40 @@ +from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from scribe.models import Base +from scribe.models.base import TimestampMixin + + +class RepoBinding(Base, TimestampMixin): + """Maps a git repository to the Scribe project it represents. + + The SessionStart hook sends the working repo's normalized remote + (`repo_key`, e.g. ``git.fabledsword.com/bvandeusen/fabledscribe``) and the + server resolves it to a project — so the operator can work across many + repos/projects without pinning a project id in plugin config. The key is a + *stable identifier* (the git remote), not a dir-name heuristic. + """ + + __tablename__ = "repo_bindings" + __table_args__ = ( + UniqueConstraint("user_id", "repo_key", name="uq_repo_bindings_user_repo"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + project_id: Mapped[int] = mapped_column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + repo_key: Mapped[str] = mapped_column(Text, nullable=False) + + def to_dict(self) -> dict: + return { + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "repo_key": self.repo_key, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index 1d34dca..5a7bd77 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -12,6 +12,7 @@ from quart import Blueprint, g, jsonify, request from scribe.auth import admin_required, get_current_user_id, login_required from scribe.services import plugin_context as plugin_ctx_svc +from scribe.services import repo_bindings as repo_bindings_svc from scribe.services.settings import get_admin_setting, set_setting plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin") @@ -25,16 +26,33 @@ _MARKETPLACE_KEY = "plugin_marketplace_url" @plugin_bp.get("/context") @login_required async def session_context(): - """Return SessionStart context (always-on rules + optional project). + """Return SessionStart context (always-on rules + active project). - Query: project_id (optional int) — when set, includes that project's goal - and open-task count. The hook passes the bound project's id here. + Query: + repo (optional str) — the working repo's git remote. The server + resolves it to the bound project (see services/repo_bindings); an + unbound repo yields a "bind this repo" hint instead. This is how the + active project is determined — the plugin never pins a project id. + project_id (optional int) — explicit override, mainly for manual/ad-hoc + curl testing; takes precedence over `repo` when set. """ try: project_id = int(request.args.get("project_id", 0) or 0) except (TypeError, ValueError): project_id = 0 - result = await plugin_ctx_svc.build_session_context(g.user.id, project_id) + + unbound_repo = "" + repo = (request.args.get("repo") or "").strip() + if repo and not project_id: + resolved = await repo_bindings_svc.resolve_project(g.user.id, repo) + if resolved: + project_id = resolved + else: + unbound_repo = repo_bindings_svc.normalize_repo_key(repo) + + result = await plugin_ctx_svc.build_session_context( + g.user.id, project_id, unbound_repo=unbound_repo + ) return jsonify(result) diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 09c7560..a86fcdd 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -40,9 +40,19 @@ async def _topic_titles(topic_ids: set[int]) -> dict[int, str]: return {tid: title for tid, title in rows.all()} -async def build_session_context(user_id: int, project_id: int = 0) -> dict: +async def build_session_context( + user_id: int, project_id: int = 0, unbound_repo: str = "" +) -> dict: """Render the SessionStart context for a user, optionally project-scoped. + Args: + user_id: the operator. + project_id: the resolved active project (0 = none). The endpoint + resolves this from the working repo's remote, not from config. + unbound_repo: when the hook sent a repo remote that maps to no project, + its normalized key — triggers a one-line "bind this repo" hint so + the binding is self-healing. + Returns {"context": str, "rule_count": int, "project": dict | None}. `context` is markdown ready to drop into `additionalContext`; it is capped at _MAX_CHARS with an explicit truncation note so the hook can pass it @@ -86,6 +96,16 @@ async def build_session_context(user_id: int, project_id: int = 0) -> dict: f"Goal: {goal[:200]}" if goal else "", f"Open todo tasks: {open_count}", ] + elif unbound_repo: + lines += [ + "", + "## Repository not yet bound", + f"This repo (`{unbound_repo}`) isn't mapped to a Scribe project, so " + "no project context was loaded. Bind it once with " + f'`bind_repo(repo_url="{unbound_repo}", project_id=)` ' + "(call `list_projects` to find the id) and future sessions here will " + "auto-load that project's context.", + ] lines += [ "", diff --git a/src/scribe/services/repo_bindings.py b/src/scribe/services/repo_bindings.py new file mode 100644 index 0000000..45f407a --- /dev/null +++ b/src/scribe/services/repo_bindings.py @@ -0,0 +1,119 @@ +"""Repo -> project binding resolution. + +The SessionStart hook sends the working repo's git remote; this module +normalizes it to a stable `repo_key` and resolves it to a project id. The key +deliberately collapses ssh/https forms of the same remote so the operator binds +a repo once regardless of how it's cloned. +""" +from __future__ import annotations + +import re + +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.repo_binding import RepoBinding + + +def normalize_repo_key(raw: str) -> str: + """Reduce a git remote URL to a stable, scheme-agnostic ``host/owner/repo``. + + Collapses the equivalent clone URLs to one key, e.g.:: + + git@git.fabledsword.com:bvandeusen/FabledScribe.git + https://git.fabledsword.com/bvandeusen/fabledscribe.git + ssh://git@git.fabledsword.com:22/bvandeusen/fabledscribe + + all normalize to ``git.fabledsword.com/bvandeusen/fabledscribe``. + + Returns "" for empty/garbage input so callers can treat it as "no repo". + """ + s = (raw or "").strip() + if not s: + return "" + + # scp-like syntax: git@host:owner/repo(.git) -> ssh://host/owner/repo + scp = re.match(r"^[^/@]+@([^:/]+):(.+)$", s) + if scp and "://" not in s: + s = f"//{scp.group(1)}/{scp.group(2)}" + else: + # strip an explicit scheme (https://, http://, ssh://, git://, git+ssh://) + s = re.sub(r"^[a-z][a-z0-9+.\-]*://", "//", s, flags=re.IGNORECASE) + if not s.startswith("//"): + s = "//" + s + + body = s[2:] # drop leading // + # strip userinfo (user@ or user:pass@) on the authority + body = re.sub(r"^[^/]*@", "", body) + # drop an explicit port on the host (host:22/...) + body = re.sub(r"^([^/:]+):\d+", r"\1", body) + # strip trailing .git and surrounding slashes + body = body.strip("/") + if body.endswith(".git"): + body = body[:-4] + return body.lower() + + +async def resolve_project(user_id: int, raw_repo: str) -> int | None: + """Return the bound project id for a repo remote, or None if unbound.""" + key = normalize_repo_key(raw_repo) + if not key: + return None + async with async_session() as session: + row = await session.execute( + select(RepoBinding.project_id).where( + RepoBinding.user_id == user_id, RepoBinding.repo_key == key + ) + ) + return row.scalar_one_or_none() + + +async def set_binding(user_id: int, raw_repo: str, project_id: int) -> RepoBinding: + """Create or update the binding for a repo. Idempotent on (user, repo_key).""" + key = normalize_repo_key(raw_repo) + if not key: + raise ValueError("repo remote is empty or unparseable") + async with async_session() as session: + existing = await session.execute( + select(RepoBinding).where( + RepoBinding.user_id == user_id, RepoBinding.repo_key == key + ) + ) + binding = existing.scalar_one_or_none() + if binding is None: + binding = RepoBinding(user_id=user_id, repo_key=key, project_id=project_id) + session.add(binding) + else: + binding.project_id = project_id + await session.commit() + await session.refresh(binding) + return binding + + +async def list_bindings(user_id: int) -> list[RepoBinding]: + async with async_session() as session: + rows = await session.execute( + select(RepoBinding) + .where(RepoBinding.user_id == user_id) + .order_by(RepoBinding.repo_key) + ) + return list(rows.scalars().all()) + + +async def delete_binding(user_id: int, raw_repo: str) -> bool: + """Remove a repo's binding. Returns True if a row was deleted.""" + key = normalize_repo_key(raw_repo) + if not key: + return False + async with async_session() as session: + row = await session.execute( + select(RepoBinding).where( + RepoBinding.user_id == user_id, RepoBinding.repo_key == key + ) + ) + binding = row.scalar_one_or_none() + if binding is None: + return False + await session.delete(binding) + await session.commit() + return True diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index b8a809a..4b2e888 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -55,6 +55,25 @@ async def test_build_session_context_includes_project_when_scoped(): assert "Open todo tasks: 4" in out["context"] +@pytest.mark.asyncio +async def test_build_session_context_unbound_repo_emits_bind_hint(): + with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules", + AsyncMock(return_value=[_rule(1, "rule", 1)])), \ + patch("scribe.services.plugin_context._topic_titles", + AsyncMock(return_value={1: "git-workflow"})): + from scribe.services.plugin_context import build_session_context + out = await build_session_context( + user_id=7, project_id=0, unbound_repo="host/owner/repo", + ) + + ctx = out["context"] + assert out["project"] is None + assert "## Repository not yet bound" in ctx + assert 'bind_repo(repo_url="host/owner/repo"' in ctx + # No project block when unbound. + assert "## Active project" not in ctx + + @pytest.mark.asyncio async def test_build_session_context_caps_length(): many = [_rule(i, "x" * 200, 1) for i in range(200)] diff --git a/tests/test_services_repo_bindings.py b/tests/test_services_repo_bindings.py new file mode 100644 index 0000000..2e2120a --- /dev/null +++ b/tests/test_services_repo_bindings.py @@ -0,0 +1,41 @@ +import pytest + +from scribe.services.repo_bindings import normalize_repo_key + + +@pytest.mark.parametrize( + "raw", + [ + "git@git.fabledsword.com:bvandeusen/fabledscribe.git", + "git@git.fabledsword.com:bvandeusen/FabledScribe.git", + "https://git.fabledsword.com/bvandeusen/fabledscribe.git", + "https://git.fabledsword.com/bvandeusen/fabledscribe", + "http://git.fabledsword.com/bvandeusen/fabledscribe.git", + "ssh://git@git.fabledsword.com:22/bvandeusen/fabledscribe.git", + "ssh://git@git.fabledsword.com/bvandeusen/fabledscribe", + "https://user:token@git.fabledsword.com/bvandeusen/fabledscribe.git", + ], +) +def test_normalize_collapses_equivalent_remotes(raw): + assert normalize_repo_key(raw) == "git.fabledsword.com/bvandeusen/fabledscribe" + + +@pytest.mark.parametrize("raw", ["", " ", None]) +def test_normalize_empty_inputs(raw): + assert normalize_repo_key(raw) == "" + + +def test_normalize_distinguishes_different_repos(): + a = normalize_repo_key("git@host:owner/repo-a.git") + b = normalize_repo_key("git@host:owner/repo-b.git") + assert a == "host/owner/repo-a" + assert b == "host/owner/repo-b" + assert a != b + + +def test_normalize_preserves_nested_group_path(): + # GitLab-style nested namespaces must survive normalization. + assert ( + normalize_repo_key("https://gitlab.com/group/subgroup/repo.git") + == "gitlab.com/group/subgroup/repo" + )