"""Scribe-plugin support endpoints. Consumed by the Scribe Claude Code plugin (not the web UI). `GET /api/plugin/ context` is curled by the plugin's SessionStart hook to push the operator's always-on rules + active-project context into the session — Scribe's own push channel. Auth is the standard `@login_required` path, which accepts a `Bearer fmcp_` API key (a read-scoped key suffices for this GET). """ from __future__ import annotations from quart import Blueprint, g, jsonify, request from scribe.auth import admin_required, get_current_user_id, login_required from scribe.config import Config from scribe.services import plugin_context as plugin_ctx_svc from scribe.services import repo_bindings as repo_bindings_svc from scribe.services import report_check as report_check_svc from scribe.services.settings import get_admin_setting, set_setting plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin") # Setting key for the git URL of the marketplace that serves this plugin (the # Scribe app's own repo). Instance-global (stored on an admin account) so every # user's Settings page shows the real, copyable install command. _MARKETPLACE_KEY = "plugin_marketplace_url" def _int_list(raw: str | None) -> list[int]: """A comma-separated id list from the query string; non-ints dropped.""" return [int(p) for p in (raw or "").split(",") if p.strip().isdigit()] async def _project_scope() -> tuple[int, str, str]: """(project_id, repo, unbound_repo) from the request's `project_id` / `repo` query args — the one resolution every plugin endpoint shares. An explicit project_id wins; otherwise the repo remote is resolved through the caller's bindings, and a remote nobody bound comes back as `unbound_repo` (normalised) so /context can say "bind this repo". """ try: project_id = int(request.args.get("project_id", 0) or 0) except (TypeError, ValueError): project_id = 0 repo = (request.args.get("repo") or "").strip() unbound_repo = "" 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) return project_id, repo, unbound_repo @plugin_bp.get("/context") @login_required async def session_context(): """Return SessionStart context (always-on rules + active project). 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) — the project named directly, and the only key a caller outside a git repo has (#4085): the plugin's hooks send it when a `.scribe` marker file names a project. Takes precedence over `repo`. Access-checked like any other read — an id this account cannot read loads no project rather than failing. """ project_id, _repo, unbound_repo = await _project_scope() result = await plugin_ctx_svc.build_session_context( g.user.id, project_id, unbound_repo=unbound_repo ) return jsonify(result) @plugin_bp.get("/retrieve") @login_required async def autoinject_retrieve(): """Title-first knowledge auto-inject for the plugin's UserPromptSubmit hook. Given the user's prompt (`q`), returns a compact awareness hint — note titles + scores only, never bodies — for the top hits that clear the per-user gates (see services.plugin_context.build_autoinject_hint). Returns empty context (most of the time) when disabled or nothing is relevant. Query: q (str) — the user's prompt to retrieve against. repo (optional) — working repo remote; resolved to the bound project to scope the search (mirrors /context). Unbound or absent → searches all the user's notes. project_id (opt) — explicit project scope override (ad-hoc/testing). exclude_ids (opt) — comma-separated note ids already injected this session; skipped so each note injects at most once. exclude_rule_ids — comma-separated rule ids already surfaced this (opt) session. SHARED with /prior-art and /tool-rules on purpose: one session keeps ONE rule ledger, so a rule named by any arm is not re-announced by another. Ages out (#3751), so salience decays. held_rule_ids — comma-separated rule ids the session actually (opt) OPENED, observed from the `get_rule` call itself rather than claimed. A rule on exclude_rule_ids was NAMED; a rule here was READ, and the two say different things about what the reader holds — so they get different lines (#4100). Shares the ledger directory and the same clear-on-compact. TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query but nothing else: the notes menu can be disabled, thresholded and top-k'd by the operator without touching whether a rule reaches them. Composed here rather than inside either builder so neither one's early return can silently suppress the other. Rules come FIRST in the payload. A rule or preference governing the answer is more consequential than a menu of things that might be worth reading, and a reader who stops after the first block should have stopped after the right one. """ q = (request.args.get("q") or "").strip() project_id, _repo, _unbound = await _project_scope() exclude_ids = _int_list(request.args.get("exclude_ids")) exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids")) held_rule_ids = _int_list(request.args.get("held_rule_ids")) rules = await plugin_ctx_svc.build_prompt_rule_hint( g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids ) result = await plugin_ctx_svc.build_autoinject_hint( g.user.id, q, project_id=project_id, exclude_ids=exclude_ids ) blocks = [b for b in (rules["context"], result["context"]) if b] result["context"] = "\n\n".join(blocks) result["rule_ids"] = rules["rule_ids"] return jsonify(result) @plugin_bp.get("/tool-rules") @login_required async def pre_tool_rules(): """Standing rules for the plugin's PreToolUse hook on ACTIONS (#3476). Answers "does a recorded rule speak to the command about to be run?" — the sibling of /prior-art, which can only answer that question about a code write. Rules about which tool to reach for (don't curl the forge, don't stand up a stack, don't run the suite locally) had no retrieval surface at all before this, which is why they all had to live in the resident preload. Titles + trigger only, never the statement: the hint says a rule may apply and hands over `get_rule(id)`. One rule at most (RULEHINT_LIMIT), and empty most of the time. Query: tool (str) — the tool about to run, e.g. `Bash`. Used in the hint's wording, not in the search: a rule is about the action, not the harness. command (str) — the command about to run; the semantic query. Absent or blank → empty, no search. repo (optional) — working repo remote, resolved to the bound project exactly as /retrieve and /prior-art. exclude_rule_ids (opt) — comma-separated rule ids already surfaced this session. SHARED with /prior-art's ledger on purpose: one session keeps one list, so a rule named by either arm is not re-offered by the other. held_rule_ids (opt) — rule ids the session actually OPENED, as against merely named. Named and read are different claims about the reader's context, so they get different lines (#4100). Returns `context`, `rule_ids`, and `checkpoint` (#4214, milestone 419). `checkpoint` IS THE ONE PART OF THIS RESPONSE THAT IS NOT A HINT. It is empty on almost every call. When present it carries `rule_id`, `title`, `trigger`, `score` and a rendered `reason`, and it means the hook should DENY the call rather than annotate it — because every other line this endpoint returns is delivered by Claude Code alongside the tool RESULT, so it reaches the reader after the act is composed and reads as commentary on a decision already made. It is raised only for a rule (never a preference, which claims no such force), only for the ranker's top hit, only above the checkpoint threshold — well above this arm's own floor — and only when the session has NOT opened that rule. The remedy is one `get_rule` call, and the act may then be re-submitted unchanged; the hook caps stops at one per rule and five per session so a mis-set floor degrades to noise rather than to a session that cannot proceed. """ tool = (request.args.get("tool") or "tool").strip() command = request.args.get("command") or "" project_id, _repo, _unbound = await _project_scope() exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids")) held_rule_ids = _int_list(request.args.get("held_rule_ids")) result = await plugin_ctx_svc.build_tool_rule_hint( g.user.id, tool, command, project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids, ) return jsonify(result) @plugin_bp.get("/prior-art") @login_required async def write_path_prior_art(): """Prior-art hint for the plugin's PreToolUse hook on Write/Edit. Answers "what is already recorded for the file about to be written?" — by place (a snippet at this path or in its directory) and by meaning (snippets resembling the code about to be written). Titles only, gated, and empty most of the time. See services.plugin_context.build_write_path_hint. Query: path (str) — the target file, REPO-RELATIVE, matching the convention snippet locations are recorded in. An absolute path simply won't match anything. code (optional) — the code about to be written; the semantic query. Omit it and only the location arm runs. repo (optional) — working repo remote; resolved to the bound project to scope the search, exactly as /retrieve does. It is NOT used as the location `repo` filter — see the service docstring for why those two differ. project_id (opt) — explicit project scope override (ad-hoc/testing). exclude_ids (opt) — comma-separated REUSE-class ids already surfaced this session. exclude_sync_ids (opt) — comma-separated SYNC-class ids (snippets recorded AT the edited file, #2708) already surfaced. A separate channel on purpose: a reuse hint shown early must not suppress the record-sync nudge when the recorded file is edited later. exclude_rule_ids (opt) — comma-separated RULE ids already surfaced this session. Its own channel like the three above, and for the same reason: a rule named twenty turns ago should not be re-offered on every subsequent write. held_rule_ids (opt) — RULE ids the session actually OPENED, as against merely named; drives the third reference wording (#4100). exclude_derive (opt) — comma-separated derive keys (a derive group id or `canon:`) already named this session by the ledger arm (#2900); its own channel, like the two above. (Returns a `checkpoint` block on the same contract as /tool-rules — see that endpoint. The write-path HOOK deliberately does not act on it: scribe_prior_art.sh carries a tested property that it never returns a permissionDecision, on the operator's decision that a recall aid may not stand in the way of a write. The block is computed and returned so that decision can be revisited with evidence rather than re-argued, and so a change of mind is a hook edit and not a feature.) shapes (opt) — comma-separated `kind:name` definitions the hook found in (or enclosing) the payload, kind being css|sym. The shape ledger's write-path feed (#2791): when the session recently PULLED a snippet this payload references or resembles, these land as instance rows (classified_by=hook). Honoured only for a caller allowed to write — a read-scoped key still gets the hint, and never changes accounting on a GET. """ path = (request.args.get("path") or "").strip() code = request.args.get("code") or "" project_id, repo, _unbound = await _project_scope() exclude_ids = _int_list(request.args.get("exclude_ids")) exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids")) exclude_derive = [ p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip() ] exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids")) held_rule_ids = _int_list(request.args.get("held_rule_ids")) shapes = _parse_shapes(request.args.get("shapes") or "") api_key = getattr(g, "api_key", None) may_stamp = api_key is None or getattr(api_key, "scope", "") == "write" result = await plugin_ctx_svc.build_write_path_hint( g.user.id, path, code=code, project_id=project_id, exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids, stamp_shapes=shapes if may_stamp else None, repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "", exclude_derive=exclude_derive, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids, ) return jsonify(result) # The hook names at most a dozen definitions per write; anything past that is # a generated file, not a shape being instantiated. _SHAPES_CAP = 12 def _parse_shapes(raw: str) -> list[tuple[str, str]]: """`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")]. Unknown kinds and empty names are dropped, duplicates collapse, and the list is capped — the hook's own cap, re-applied so the contract holds for any caller.""" out: list[tuple[str, str]] = [] for part in raw.split(","): kind, _sep, name = part.strip().partition(":") kind, name = kind.strip(), name.strip() if kind in ("css", "sym") and name and (kind, name) not in out: out.append((kind, name)) if len(out) >= _SHAPES_CAP: break return out @plugin_bp.get("/report-check") @login_required async def report_check(): """Record what a Stop hook found in a reply that closed a task (milestone 409 step 5). The hook decides which completion sections the reply lacks — a local check of text it can read — and reports the outcome here. For `blocked` the response carries the `reason` to send the agent back with: the words are the server's, so every client's hook says the same thing (plugin/PACKAGING.md). A hook blocks only on a `reason` it received, which means only on a block that was recorded. A GET for the reason every plugin endpoint is one: a read-scoped key must be enough to run the plugin, and this records telemetry the way /retrieve records a retrieval log. Query: outcome (str) — passed | blocked | passed_after_rewrite | missing_after_rewrite. Anything else is a 400. missing (opt) — comma-separated sections the reply lacked: "where it sits", "needs you", "next". task_ids (opt) — comma-separated ids of the tasks the turn closed. repo (opt) — working repo remote, resolved like the other arms. """ outcome = (request.args.get("outcome") or "").strip() if outcome not in report_check_svc.OUTCOMES: return jsonify({"error": f"outcome must be one of {list(report_check_svc.OUTCOMES)}"}), 400 missing = [m for m in (request.args.get("missing") or "").split(",") if m.strip()] task_ids = _int_list(request.args.get("task_ids"))[:20] project_id, _repo, _unbound = await _project_scope() await report_check_svc.record_report_check( g.user.id, outcome, missing=missing, task_ids=task_ids, project_id=project_id or None, ) body: dict = {"status": "ok"} if outcome == "blocked": body["reason"] = report_check_svc.block_reason(missing) return jsonify(body) @plugin_bp.get("/processes") @login_required async def process_manifest(): """Stored Processes as skill-stub specs for the plugin's sync script. The plugin's `scribe_sync_processes.sh` (run at SessionStart and via the `/scribe:sync` command) curls this and writes one auto-surfacing local skill per Process into ~/.claude/skills/. See services/plugin_context. build_process_manifest. A read-scoped API key suffices. """ result = await plugin_ctx_svc.build_process_manifest(g.user.id) return jsonify(result) @plugin_bp.get("/marketplace-url") @login_required async def get_marketplace_url(): """The plugin marketplace git URL, readable by any logged-in user so their Settings page can show a copyable install command. Falls back to the config default (this deployment's own repo) when no admin override is set.""" url = await get_admin_setting(_MARKETPLACE_KEY, default=Config.PLUGIN_MARKETPLACE_URL) return jsonify({"marketplace_url": url}) @plugin_bp.put("/marketplace-url") @admin_required async def set_marketplace_url(): """Admin-set the marketplace git URL (e.g. this app's own repo).""" data = await request.get_json() or {} url = (data.get("marketplace_url") or "").strip() if url: scheme = url.split("://")[0].lower() if "://" in url else "" if scheme not in ("http", "https"): return jsonify({"error": "Marketplace URL must use http or https"}), 400 await set_setting(get_current_user_id(), _MARKETPLACE_KEY, url) return jsonify({"status": "ok"})