feat(plugin): resolve session project from git remote, not a pinned project_id
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m0s

The SessionStart hook asked for a project_id via plugin userConfig, which pins
one install to a single project — wrong for an operator working across many
repos/projects. Resolve the active project server-side from the working repo's
git remote instead (a stable identifier, not a dir-name guess).

- repo_bindings table (migration 0064) + RepoBinding model: (user, repo_key) ->
  project, FKs CASCADE.
- services/repo_bindings: normalize_repo_key collapses ssh/https/scp/creds/port/
  .git to host/owner/repo; resolve/set/list/delete.
- GET /api/plugin/context takes ?repo=<remote>; unbound repo -> a "bind this
  repo" hint with a ready bind_repo() call. project_id kept as manual override.
- MCP tools: bind_repo / list_repo_bindings / unbind_repo.
- Hook sends ?repo=$(git remote get-url origin) URL-encoded; all project_id
  handling removed. plugin.json drops the project_id userConfig (0.1.2 -> 0.1.3).
- Tests: normalize equivalence classes + unbound-hint rendering.

Refs task 755 (Scribe-as-plugin push channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:33:58 -04:00
parent 6cc47c7222
commit 8fe571e175
12 changed files with 410 additions and 19 deletions
+1 -6
View File
@@ -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."
}
}
}
+18 -7
View File
@@ -7,14 +7,18 @@
# CLAUDE_PLUGIN_OPTION_<key> 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}" \