diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 63c107a..7b5115b 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -144,8 +144,16 @@ const claudeCodeCommand = computed(() => { const _MKT_KEY = 'plugin_marketplace_url'; const pluginMarketplaceUrl = ref(localStorage.getItem(_MKT_KEY) || ''); watch(pluginMarketplaceUrl, (v) => localStorage.setItem(_MKT_KEY, (v || '').trim())); +// Instance default, set by an admin (Admin tab) and loaded on mount. Used when +// the per-browser field is blank so the install command is copyable out of the box. +const serverMarketplaceUrl = ref(''); +const adminMarketplaceUrl = ref(''); +const savingMarketplaceUrl = ref(false); +const marketplaceUrlSaved = ref(false); const pluginInstallCommands = computed(() => { - const mkt = (pluginMarketplaceUrl.value || '').trim() || '.git'; + const mkt = (pluginMarketplaceUrl.value || '').trim() + || serverMarketplaceUrl.value + || '.git'; return `/plugin marketplace add ${mkt}\n/plugin install scribe@scribe-plugin`; }); @@ -429,6 +437,17 @@ onMounted(async () => { searxngConfigured.value = false; } + // Plugin marketplace URL (instance default; readable by all users so the + // install command in MCP Access is copyable). + try { + const mk = await apiGet<{ marketplace_url: string }>("/api/plugin/marketplace-url"); + serverMarketplaceUrl.value = mk.marketplace_url || ""; + adminMarketplaceUrl.value = mk.marketplace_url || ""; + if (!pluginMarketplaceUrl.value) pluginMarketplaceUrl.value = mk.marketplace_url || ""; + } catch { + // not configured yet + } + // Load admin settings if (authStore.isAdmin) { try { @@ -658,6 +677,26 @@ async function saveBaseUrl() { } } +async function saveMarketplaceUrl() { + savingMarketplaceUrl.value = true; + marketplaceUrlSaved.value = false; + try { + const url = adminMarketplaceUrl.value.trim(); + await apiPut("/api/plugin/marketplace-url", { marketplace_url: url }); + serverMarketplaceUrl.value = url; + // Reflect the new instance default in the copyable field unless the user + // set their own per-browser override. + if (!localStorage.getItem(_MKT_KEY)) pluginMarketplaceUrl.value = url; + marketplaceUrlSaved.value = true; + setTimeout(() => (marketplaceUrlSaved.value = false), 2000); + } catch (e) { + const body = (e as { body?: { error?: string } }).body; + toastStore.show(body?.error || "Failed to save marketplace URL", "error"); + } finally { + savingMarketplaceUrl.value = false; + } +} + async function handleRestoreFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -1663,6 +1702,31 @@ function formatUserDate(iso: string): string { +
+

Plugin marketplace

+

+ Git URL of the repo that ships this Scribe plugin (usually this app's own repo). + Shown to every user in MCP Access so the install command is + copyable. Example: https://git.example.com/you/Scribe.git +

+
+ + +
+
+ + Saved! +
+
+

Email / SMTP

Configure SMTP to enable email notifications for all users.

diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f5afc61..9b1c34d 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,9 +1,15 @@ { "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.0", + "version": "0.1.1", "author": { "name": "Bryan Van Deusen" }, - "mcpServers": "./.mcp.json", + "mcpServers": { + "scribe": { + "type": "http", + "url": "${user_config.api_endpoint}/mcp", + "headers": { "Authorization": "Bearer ${user_config.api_token}" } + } + }, "userConfig": { "api_endpoint": { "type": "string", diff --git a/plugin/README.md b/plugin/README.md index 77d6104..8b3ed7c 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -33,7 +33,7 @@ On install you'll be asked for: ## What gets wired -- `.mcp.json` → the `scribe` MCP server at `/mcp` (Bearer auth). +- `plugin.json` `mcpServers` → the `scribe` MCP server at `/mcp` (Bearer auth). - `hooks/hooks.json` → SessionStart hook (`hooks/scribe_session_context.sh`), **fail-open**: if Scribe is unreachable it injects nothing and never blocks the session. diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index f6c1af0..1d34dca 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -10,11 +10,17 @@ from __future__ import annotations from quart import Blueprint, g, jsonify, request -from scribe.auth import login_required +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.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 @@ -30,3 +36,25 @@ async def session_context(): project_id = 0 result = await plugin_ctx_svc.build_session_context(g.user.id, project_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.""" + return jsonify({"marketplace_url": await get_admin_setting(_MARKETPLACE_KEY)}) + + +@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"}) diff --git a/src/scribe/services/settings.py b/src/scribe/services/settings.py index 9502944..20dcbbc 100644 --- a/src/scribe/services/settings.py +++ b/src/scribe/services/settings.py @@ -4,10 +4,28 @@ from sqlalchemy import delete as sa_delete, select from scribe.models import async_session from scribe.models.setting import Setting +from scribe.models.user import User logger = logging.getLogger(__name__) +async def get_admin_setting(key: str, default: str = "") -> str: + """Read an instance-global setting (one stored on an admin account). + + Used for settings that aren't per-user — e.g. the plugin marketplace URL + shown to everyone in Settings. Mirrors the admin-scoped lookup used for + the application base URL. + """ + async with async_session() as session: + result = await session.execute( + select(Setting) + .join(User, Setting.user_id == User.id) + .where(User.role == "admin", Setting.key == key) + ) + setting = result.scalars().first() + return setting.value if setting and setting.value else default + + async def get_setting(user_id: int, key: str, default: str = "") -> str: async with async_session() as session: result = await session.execute(