"""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.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" @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) — 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 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) @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. """ q = (request.args.get("q") or "").strip() 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() if repo and not project_id: resolved = await repo_bindings_svc.resolve_project(g.user.id, repo) if resolved: project_id = resolved exclude_ids = [ int(p) for p in (request.args.get("exclude_ids") or "").split(",") if p.strip().isdigit() ] result = await plugin_ctx_svc.build_autoinject_hint( g.user.id, q, project_id=project_id, exclude_ids=exclude_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 ids already surfaced this session. """ path = (request.args.get("path") or "").strip() code = request.args.get("code") or "" 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() if repo and not project_id: resolved = await repo_bindings_svc.resolve_project(g.user.id, repo) if resolved: project_id = resolved exclude_ids = [ int(p) for p in (request.args.get("exclude_ids") or "").split(",") if p.strip().isdigit() ] result = await plugin_ctx_svc.build_write_path_hint( g.user.id, path, code=code, project_id=project_id, exclude_ids=exclude_ids ) return jsonify(result) @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"})