diff --git a/cli/README.md b/cli/README.md index df89e19e..1c63f3f3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,8 +1,45 @@ # super-agent -One-line installer for Super Agent Skill packages. Drops the right files -into your project so Claude Code, Cursor, Continue, or Cline picks the -skill up automatically. +Plug-and-play CLI for Super Agent Skill: install skill packages locally **and** +get a fully authenticated MCP connection in one command. + +## Connect an MCP client (OAuth, zero JSON editing) + +```bash +npx super-agent connect --client claude-code +# logs in via your browser (loopback PKCE), then wires the client for you +``` + +`connect` runs the full OAuth flow (dynamic client registration → PKCE → +browser consent → token exchange), stores the token in +`~/.superagentskill/credentials.json` (chmod 600), and writes/patches the +target client's MCP config so even the auth-gated write tools work. + +Supported clients: `claude-code`, `claude` (Desktop), `cursor`, `codex`, +`vscode`, `windsurf`. + +```bash +npx super-agent login # OAuth only +npx super-agent status # show login state / token expiry +npx super-agent logout # revoke + forget the token +npx super-agent setup cursor # (re)write a client config from saved creds +npx super-agent mcp # local stdio <-> remote HTTP MCP bridge +``` + +### The local bridge + +`super-agent mcp` is a stdio MCP server that proxies to +`https://superagentskill.com/api/mcp`, injecting your OAuth token and +auto-refreshing it. Point any stdio-only client at it: + +```jsonc +{ "mcpServers": { "super-agent-skill": { "command": "npx", "args": ["-y", "super-agent", "mcp"] } } } +``` + +This is what makes the connection truly plug and play for clients whose MCP +OAuth support is flaky or absent — auth is handled entirely by the CLI. + +## Install skill packages ```bash npx super-agent install code-reviewer @@ -10,11 +47,7 @@ npx super-agent install code-reviewer # → .cursor/rules/code-reviewer.mdc # → .continue/skills/code-reviewer.md # → .cline/skills/code-reviewer.md -``` -## Commands - -```bash npx super-agent install [--target claude|cursor|continue|cline|all] npx super-agent list [--query ] npx super-agent search @@ -28,7 +61,6 @@ npx super-agent info ## Why use it -- **No login** for public packages — pulls straight from the public registry. -- **Trust signal** — every package comes with a verifiable Trust Score badge - (`/api/badges/trust/.svg`). -- **Cross-IDE** — one command installs to every agent you use. +- **One command** to a working, authenticated MCP connection — no hand-edited JSON. +- **No login** needed for public skill packages; OAuth only when you want write tools. +- **Cross-IDE** — installs to every agent you use, with a token-injecting bridge fallback. diff --git a/cli/package.json b/cli/package.json index a739145e..27c7518a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "super-agent", - "version": "0.1.0", - "description": "Install Super Agent Skill packages locally for Claude / Cursor / Continue / Cline.", + "version": "0.2.0", + "description": "Plug-and-play Super Agent Skill MCP: OAuth login, client auto-config, local stdio bridge, and skill package install for Claude / Cursor / Codex / VS Code / Windsurf.", "type": "module", "bin": { "super-agent": "./super-agent.mjs" diff --git a/cli/super-agent.mjs b/cli/super-agent.mjs index 76a58957..6c8b2c25 100644 --- a/cli/super-agent.mjs +++ b/cli/super-agent.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Super Agent Skill CLI — one-line distribution for any IDE/agent that reads -// local instruction files. +// Super Agent Skill CLI — install skills locally AND get a fully plug-and-play +// MCP connection (OAuth login, config writing, and a local stdio bridge). // // Usage: // npx super-agent install [--target claude|cursor|continue|cline|all] @@ -8,19 +8,37 @@ // npx super-agent search // npx super-agent info // -// Installs an Anthropic-compatible SKILL.md (or each target's local convention) -// into the current working directory. No login required for public packages — -// downloads via the public registry HTTPS endpoints. +// npx super-agent connect [--client claude-code|claude|cursor|codex|vscode|windsurf] +// npx super-agent login # OAuth (browser, loopback PKCE) only +// npx super-agent status +// npx super-agent logout +// npx super-agent setup # write/patch the MCP config for a client +// npx super-agent mcp # local stdio <-> remote HTTP MCP bridge +// +// `connect` is the one-shot path: it logs you in via OAuth and wires the +// chosen client so write tools work with zero manual JSON editing. import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { homedir, platform } from "node:os"; +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; -const REGISTRY = process.env.SUPER_AGENT_REGISTRY ?? "https://superagentskill.com"; +const REGISTRY = (process.env.SUPER_AGENT_REGISTRY ?? "https://superagentskill.com").replace(/\/+$/, ""); const TELEMETRY = process.env.SUPER_AGENT_TELEMETRY !== "0"; +const MCP_ENDPOINT = `${REGISTRY}/api/mcp`; +const CRED_DIR = join(homedir(), ".superagentskill"); +const CRED_FILE = join(CRED_DIR, "credentials.json"); + +// Commands that run a server / long process must NOT be force-exited. +const LONG_RUNNING = new Set(["connect", "login", "mcp"]); const [cmd, ...rest] = process.argv.slice(2); if (!cmd || cmd === "--help" || cmd === "-h") { - printHelp(); process.exit(0); + printHelp(); + process.exit(0); } try { @@ -28,23 +46,45 @@ try { else if (cmd === "list") await cmdList(rest); else if (cmd === "search") await cmdSearch(rest); else if (cmd === "info") await cmdInfo(rest); - else { console.error(`unknown command: ${cmd}`); printHelp(); process.exit(1); } + else if (cmd === "connect") await cmdConnect(rest); + else if (cmd === "login") await cmdLogin(); + else if (cmd === "status") await cmdStatus(); + else if (cmd === "logout") await cmdLogout(); + else if (cmd === "setup") await cmdSetup(rest); + else if (cmd === "mcp") await cmdMcpBridge(); + else { + console.error(`unknown command: ${cmd}`); + printHelp(); + process.exit(1); + } } catch (e) { console.error(`✗ ${e.message}`); process.exit(2); } -// fetch keep-alive sockets can keep the loop alive ~30s; force a clean exit. -setImmediate(() => process.exit(0)); + +// fetch keep-alive sockets can keep the loop alive ~30s; force a clean exit — +// but never for commands that intentionally keep running. +if (!LONG_RUNNING.has(cmd)) setImmediate(() => process.exit(0)); function printHelp() { - console.log(`super-agent — install AI agent skills locally + console.log(`super-agent — install AI agent skills + plug-and-play MCP -Commands: +Skill install: install [--target claude|cursor|continue|cline|all] default: all list [--query ] search info +MCP connection (plug and play): + connect [--client ] OAuth login + auto-wire a client in one step + login OAuth login only (browser, loopback PKCE) + status show login state / token expiry + logout revoke and forget the stored token + setup write/patch the MCP config for a client + mcp run a local stdio <-> remote HTTP MCP bridge + + clients: claude-code | claude | cursor | codex | vscode | windsurf + Environment: SUPER_AGENT_REGISTRY override registry origin (default https://superagentskill.com) SUPER_AGENT_TELEMETRY set to 0 to disable anonymized install telemetry @@ -52,11 +92,18 @@ Environment: } function parseFlags(args) { - const positional = []; const flags = {}; + const positional = []; + const flags = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; - if (a.startsWith("--")) { const n = args[i + 1]; if (!n || n.startsWith("--")) flags[a.slice(2)] = true; else { flags[a.slice(2)] = n; i++; } } - else positional.push(a); + if (a.startsWith("--")) { + const n = args[i + 1]; + if (!n || n.startsWith("--")) flags[a.slice(2)] = true; + else { + flags[a.slice(2)] = n; + i++; + } + } else positional.push(a); } return { positional, flags }; } @@ -73,6 +120,8 @@ async function getText(path) { return res.text(); } +/* ---------------- skill install (unchanged behavior) ---------------- */ + async function cmdInstall(args) { const { positional, flags } = parseFlags(args); const slug = positional[0]; @@ -81,10 +130,7 @@ async function cmdInstall(args) { console.log(`→ fetching ${slug} from ${REGISTRY}`); const info = await getJson(`/api/public/packages/${slug}`); - const skillMd = await getText(`/api/skills/${slug}/export.md`).catch(async () => { - // Fallback: synthesize from the public manifest fields. - return synthesizeSkillMd(info); - }); + const skillMd = await getText(`/api/skills/${slug}/export.md`).catch(async () => synthesizeSkillMd(info)); const targets = target === "all" ? ["claude", "cursor", "continue", "cline"] : [target]; for (const t of targets) writeForTarget(t, slug, skillMd); @@ -97,13 +143,16 @@ async function cmdInstall(args) { function writeForTarget(target, slug, skillMd) { const map = { - claude: `.claude/skills/${slug}/SKILL.md`, - cursor: `.cursor/rules/${slug}.mdc`, + claude: `.claude/skills/${slug}/SKILL.md`, + cursor: `.cursor/rules/${slug}.mdc`, continue: `.continue/skills/${slug}.md`, - cline: `.cline/skills/${slug}.md`, + cline: `.cline/skills/${slug}.md`, }; const path = map[target]; - if (!path) { console.warn(` ! unknown target: ${target}`); return; } + if (!path) { + console.warn(` ! unknown target: ${target}`); + return; + } mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, skillMd); console.log(` + ${path}`); @@ -113,28 +162,24 @@ async function cmdList(args) { const { flags } = parseFlags(args); const q = flags.query ?? ""; const list = await getJson(`/api/public/packages?type=skill${q ? `&q=${encodeURIComponent(q)}` : ""}`); - for (const p of list.items ?? list) { - console.log(`${p.slug.padEnd(36)} ${p.name ?? ""}`); - } + for (const p of list.items ?? list) console.log(`${p.slug.padEnd(36)} ${p.name ?? ""}`); } async function cmdSearch(args) { if (!args[0]) throw new Error("search requires a query"); const res = await getJson(`/api/public/search?q=${encodeURIComponent(args.join(" "))}`); - for (const r of res.results ?? []) { + for (const r of res.results ?? []) console.log(`${r.type.padEnd(10)} ${r.slug.padEnd(32)} ${r.score?.toFixed?.(2) ?? ""} ${r.snippet ?? ""}`); - } } async function cmdInfo(args) { const slug = args[0]; if (!slug) throw new Error("info requires "); - const info = await getJson(`/api/public/packages/${slug}`); - console.log(JSON.stringify(info, null, 2)); + console.log(JSON.stringify(await getJson(`/api/public/packages/${slug}`), null, 2)); } function synthesizeSkillMd(info) { - const lines = [ + return [ `# ${info.name ?? info.slug}`, ``, info.description ?? "", @@ -147,16 +192,16 @@ function synthesizeSkillMd(info) { ``, `---`, `Installed from ${REGISTRY}/packs/${info.slug}`, - ]; - return lines.join("\n"); + ].join("\n"); } function reportTelemetry(event) { if (!TELEMETRY) return; const body = JSON.stringify({ ...event, latency_ms: 0, workspace_id: hashCwd() }); - // Fire-and-forget; never block the CLI. fetch(`${REGISTRY}/api/telemetry`, { - method: "POST", headers: { "content-type": "application/json" }, body, + method: "POST", + headers: { "content-type": "application/json" }, + body, }).catch(() => {}); } @@ -164,7 +209,442 @@ function hashCwd() { try { const cwd = process.cwd(); const pkg = existsSync("package.json") ? JSON.parse(readFileSync("package.json", "utf8"))?.name : ""; - // No salt — server-side anonymization will hash again. return `${pkg || "anon"}:${cwd.split("/").slice(-2).join("/")}`; - } catch { return "anon"; } + } catch { + return "anon"; + } +} + +/* ---------------- OAuth (loopback PKCE) ---------------- */ + +function b64url(buf) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function loadCreds() { + try { + return JSON.parse(readFileSync(CRED_FILE, "utf8")); + } catch { + return null; + } +} + +function saveCreds(creds) { + mkdirSync(CRED_DIR, { recursive: true }); + writeFileSync(CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); +} + +function openBrowser(url) { + const cmds = + platform() === "darwin" + ? ["open", [url]] + : platform() === "win32" + ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; + try { + const p = spawn(cmds[0], cmds[1], { stdio: "ignore", detached: true }); + p.on("error", () => {}); + p.unref(); + } catch { + /* fall back to manual paste */ + } +} + +async function discover() { + // RFC 9728 → RFC 8414. Fail soft to known defaults if discovery is blocked. + const fallback = { + authorization_endpoint: `${REGISTRY}/oauth/authorize`, + token_endpoint: `${REGISTRY}/api/public/oauth/token`, + registration_endpoint: `${REGISTRY}/api/public/oauth/register`, + revocation_endpoint: `${REGISTRY}/api/public/oauth/revoke`, + }; + try { + const pr = await getJson(`/.well-known/oauth-protected-resource/api/mcp`).catch(() => + getJson(`/.well-known/oauth-protected-resource`), + ); + const as = (pr.authorization_servers?.[0] ?? REGISTRY).replace(/\/+$/, ""); + const meta = await fetch(`${as}/.well-known/oauth-authorization-server`, { + headers: { accept: "application/json" }, + }).then((r) => (r.ok ? r.json() : null)); + return meta ?? fallback; + } catch { + return fallback; + } +} + +function startCallbackServer() { + return new Promise((resolve) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + const redirectUri = `http://127.0.0.1:${port}/callback`; + const codePromise = new Promise((res, rej) => { + server.on("request", (req, response) => { + const u = new URL(req.url, redirectUri); + if (u.pathname !== "/callback") { + response.writeHead(404).end(); + return; + } + const err = u.searchParams.get("error"); + const code = u.searchParams.get("code"); + const state = u.searchParams.get("state"); + response.writeHead(200, { "content-type": "text/html" }); + response.end( + `Super Agent Skill` + + `` + + (err + ? `

Authorization failed

${err}

` + : `

✓ Connected

You can close this tab and return to your terminal.

`) + + ``, + ); + server.close(); + if (err) rej(new Error(`authorization denied: ${err}`)); + else res({ code, state }); + }); + }); + resolve({ redirectUri, codePromise }); + }); + }); +} + +async function oauthLogin() { + const meta = await discover(); + const { redirectUri, codePromise } = await startCallbackServer(); + + // Dynamic client registration with the real loopback redirect. + const reg = await fetch(meta.registration_endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "Super Agent CLI", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + software_id: "super-agent-cli", + }), + }); + if (!reg.ok) throw new Error(`client registration failed: HTTP ${reg.status} ${await reg.text()}`); + const client = await reg.json(); + if (!client.client_id) throw new Error("registration response missing client_id"); + + const verifier = b64url(randomBytes(32)); + const challenge = b64url(createHash("sha256").update(verifier).digest()); + const state = b64url(randomBytes(16)); + + const authUrl = new URL(meta.authorization_endpoint); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", client.client_id); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", "mcp:read mcp:write"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("resource", MCP_ENDPOINT); + + console.log(`\n→ Opening your browser to authorize Super Agent Skill…`); + console.log(` If it doesn't open, paste this URL:\n ${authUrl}\n`); + openBrowser(authUrl.toString()); + + const { code, state: returnedState } = await codePromise; + if (returnedState !== state) throw new Error("state mismatch — aborting (possible CSRF)"); + + const tok = await fetch(meta.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: client.client_id, + code_verifier: verifier, + }), + }); + if (!tok.ok) throw new Error(`token exchange failed: HTTP ${tok.status} ${await tok.text()}`); + const t = await tok.json(); + + const creds = { + client_id: client.client_id, + access_token: t.access_token, + refresh_token: t.refresh_token ?? null, + scope: t.scope ?? "mcp:read mcp:write", + expires_at: Date.now() + (t.expires_in ?? 3600) * 1000, + token_endpoint: meta.token_endpoint, + revocation_endpoint: meta.revocation_endpoint, + }; + saveCreds(creds); + return creds; +} + +async function ensureToken() { + let creds = loadCreds(); + if (!creds) throw new Error('not logged in — run "super-agent login" first'); + if (Date.now() < creds.expires_at - 60_000) return creds; + if (!creds.refresh_token) return creds; // will 401 → caller can re-login + const res = await fetch(creds.token_endpoint ?? `${REGISTRY}/api/public/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: creds.refresh_token, + client_id: creds.client_id, + }), + }); + if (!res.ok) return creds; + const t = await res.json(); + creds = { + ...creds, + access_token: t.access_token, + refresh_token: t.refresh_token ?? creds.refresh_token, + expires_at: Date.now() + (t.expires_in ?? 3600) * 1000, + }; + saveCreds(creds); + return creds; +} + +/* ---------------- connect / login / status / logout ---------------- */ + +async function cmdLogin() { + const creds = await oauthLogin(); + console.log(`\n✓ Logged in. Token saved to ${CRED_FILE}`); + console.log(` scope: ${creds.scope}`); + process.exit(0); +} + +async function cmdStatus() { + const creds = loadCreds(); + if (!creds) { + console.log("Not logged in. Run: super-agent login"); + return; + } + const expired = Date.now() >= creds.expires_at; + console.log(`Logged in (${REGISTRY})`); + console.log(` scope: ${creds.scope}`); + console.log(` access: ${expired ? "expired" : "valid"}${creds.refresh_token ? " (auto-refresh on)" : ""}`); + console.log(` expires_at: ${new Date(creds.expires_at).toISOString()}`); + console.log(` file: ${CRED_FILE}`); +} + +async function cmdLogout() { + const creds = loadCreds(); + if (!creds) { + console.log("Already logged out."); + return; + } + try { + await fetch(creds.revocation_endpoint ?? `${REGISTRY}/api/public/oauth/revoke`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: creds.refresh_token ?? creds.access_token }), + }); + } catch { + /* best effort */ + } + try { + writeFileSync(CRED_FILE, "{}", { mode: 0o600 }); + } catch { + /* ignore */ + } + console.log("✓ Logged out and token revoked."); +} + +async function cmdConnect(args) { + const { flags } = parseFlags(args); + console.log(`Super Agent Skill — connecting (${REGISTRY})`); + if (!loadCreds()?.access_token || Date.now() >= (loadCreds()?.expires_at ?? 0)) { + await oauthLogin(); + console.log(`✓ OAuth complete.`); + } else { + console.log(`✓ Reusing existing login (super-agent status to inspect).`); + } + const client = (flags.client ?? "claude-code").toLowerCase(); + await writeClientConfig(client); + console.log(`\nDone. Restart ${client} and the Super Agent Skill tools (incl. write tools) will be available.`); + process.exit(0); +} + +/* ---------------- client config writers ---------------- */ + +function patchJsonFile(file, mutate) { + mkdirSync(dirname(file), { recursive: true }); + let json = {}; + if (existsSync(file)) { + try { + json = JSON.parse(readFileSync(file, "utf8") || "{}"); + } catch { + throw new Error(`existing ${file} is not valid JSON — fix or remove it first`); + } + } + mutate(json); + writeFileSync(file, JSON.stringify(json, null, 2)); + console.log(` + ${file}`); +} + +async function writeClientConfig(client) { + const creds = loadCreds(); + const bearer = creds?.access_token; + const home = homedir(); + const httpEntry = { + url: MCP_ENDPOINT, + ...(bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : {}), + }; + + switch (client) { + case "claude-code": { + // Claude Code reuses the OAuth-capable HTTP transport directly: it will + // run the same browser flow on first use. The CLI command is the + // canonical install path; print it (and a bridge fallback). + console.log(`\nRun this once (Claude Code handles OAuth natively):\n`); + console.log(` claude mcp add --transport http super-agent-skill ${MCP_ENDPOINT}\n`); + console.log(`If your Claude Code build can't do MCP OAuth, use the local bridge instead:\n`); + console.log( + ` claude mcp add super-agent-skill -- npx -y super-agent mcp\n` + + ` (the bridge injects your saved token automatically)\n`, + ); + return; + } + case "claude": { + const file = + platform() === "darwin" + ? join(home, "Library/Application Support/Claude/claude_desktop_config.json") + : platform() === "win32" + ? join(process.env.APPDATA ?? join(home, "AppData/Roaming"), "Claude/claude_desktop_config.json") + : join(home, ".config/Claude/claude_desktop_config.json"); + patchJsonFile(file, (j) => { + j.mcpServers = j.mcpServers ?? {}; + // Desktop is most reliable via the local bridge (handles auth refresh). + j.mcpServers["super-agent-skill"] = { command: "npx", args: ["-y", "super-agent", "mcp"] }; + }); + return; + } + case "cursor": { + patchJsonFile(join(process.cwd(), ".cursor/mcp.json"), (j) => { + j.mcpServers = j.mcpServers ?? {}; + j.mcpServers["super-agent-skill"] = httpEntry; + }); + return; + } + case "vscode": { + patchJsonFile(join(home, ".config/Code/User/settings.json"), (j) => { + j.mcp = j.mcp ?? {}; + j.mcp.servers = j.mcp.servers ?? {}; + j.mcp.servers["super-agent-skill"] = { type: "http", ...httpEntry }; + }); + return; + } + case "windsurf": { + patchJsonFile(join(home, ".codeium/windsurf/mcp_config.json"), (j) => { + j.mcpServers = j.mcpServers ?? {}; + j.mcpServers["super-agent-skill"] = { + serverUrl: MCP_ENDPOINT, + ...(bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : {}), + }; + }); + return; + } + case "codex": { + // Codex config.toml has no JSON; the local bridge is the clean path. + const file = join(home, ".codex", "config.toml"); + mkdirSync(dirname(file), { recursive: true }); + const block = + `\n[mcp_servers.super-agent-skill]\n` + + `command = "npx"\n` + + `args = ["-y", "super-agent", "mcp"]\n`; + const prev = existsSync(file) ? readFileSync(file, "utf8") : ""; + if (prev.includes("[mcp_servers.super-agent-skill]")) { + console.log(` = ${file} already has super-agent-skill (left as-is)`); + } else { + writeFileSync(file, prev + block); + console.log(` + ${file}`); + } + return; + } + default: + throw new Error( + `unknown client "${client}". Use: claude-code | claude | cursor | codex | vscode | windsurf`, + ); + } +} + +async function cmdSetup(args) { + const client = (args[0] ?? "").toLowerCase(); + if (!client) throw new Error("setup requires (claude-code|claude|cursor|codex|vscode|windsurf)"); + if (!loadCreds()?.access_token) + console.log('! not logged in — run "super-agent login" first for write-tool access.'); + await writeClientConfig(client); +} + +/* ---------------- local stdio <-> HTTP MCP bridge ---------------- */ +// Lets ANY stdio-only MCP client speak to the remote Streamable HTTP server +// with the saved OAuth token injected and auto-refreshed. This is the +// "runs locally" path that makes the connection truly plug and play. + +async function cmdMcpBridge() { + let sessionId = null; + const rl = createInterface({ input: process.stdin }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + let creds; + try { + creds = await ensureToken(); + } catch { + creds = null; // anonymous: read-only tools still work + } + const headers = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }; + if (creds?.access_token) headers.authorization = `Bearer ${creds.access_token}`; + if (sessionId) headers["mcp-session-id"] = sessionId; + + let res; + try { + res = await fetch(MCP_ENDPOINT, { method: "POST", headers, body: trimmed }); + } catch (e) { + emit({ jsonrpc: "2.0", id: safeId(trimmed), error: { code: -32000, message: `bridge fetch failed: ${e.message}` } }); + continue; + } + const sid = res.headers.get("mcp-session-id"); + if (sid) sessionId = sid; + + if (res.status === 401) { + emit({ + jsonrpc: "2.0", + id: safeId(trimmed), + error: { code: -32001, message: "unauthorized — run `super-agent login` to enable write tools" }, + }); + continue; + } + + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("text/event-stream")) { + const text = await res.text(); + for (const chunk of text.split(/\n\n/)) { + const dataLines = chunk + .split("\n") + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()); + if (!dataLines.length) continue; + const payload = dataLines.join(""); + if (payload && payload !== "[DONE]") process.stdout.write(payload + "\n"); + } + } else { + const text = (await res.text()).trim(); + if (text) process.stdout.write(text + "\n"); + } + } +} + +function emit(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function safeId(line) { + try { + return JSON.parse(line).id ?? null; + } catch { + return null; + } } diff --git a/src/lib/mcp/tools/skills.ts b/src/lib/mcp/tools/skills.ts index c1c1b039..657c599c 100644 --- a/src/lib/mcp/tools/skills.ts +++ b/src/lib/mcp/tools/skills.ts @@ -35,11 +35,11 @@ export const overviewTool = defineTool({ description: "PRIMARY use case. The user has a local skill/playbook/soul/guardrail file and wants it significantly improved against the SuperAgentSkill methodology.", workflow: [ - "1. get_methodology — load the 7-pillar rubric.", - "2. review_skill — score the file (0-100 per pillar) + concrete top_actions.", - "3. — YOU apply top_actions in the user's repo.", - "4. review_skill — confirm the score went up. Iterate to grade A.", - "5. search_registry — (optional) borrow patterns from high-trust primitives.", + "1. review_skill — proprietary engine scores the file (0-100 overall + per-dimension) and returns concrete, file-specific top_actions.", + "2. — YOU apply top_actions in the user's repo.", + "3. review_skill — confirm the score went up. Iterate to grade A.", + "4. search_registry — (optional) borrow patterns from high-trust primitives.", + "(get_methodology is orientation only — the rubric/signals are server-side and not disclosed.)", ], tools: ["get_methodology", "review_skill", "search_registry", "get_package"], }, @@ -292,211 +292,275 @@ export const uploadPackagesTool = defineTool({ // the Super Agent Skill methodology. Discovery / download is secondary. // ============================================================================ -const METHODOLOGY = { - name: "Super Agent Skill methodology", - version: "1.0", - summary: - "A battle-tested rubric for designing skills, playbooks, souls and guardrails that survive contact with real models, real users and real edge cases.", - pillars: [ - { - id: "identity", - title: "1. Identity (Soul)", - goal: "Anchor the agent in a persona with values, voice, refusals and non-goals — not just a task list.", - checks: [ - "Has an explicit persona/role statement", - "States values and principles (what it cares about)", - "Defines voice & tone (concrete adjectives, not vibes)", - "Lists non-goals / what it explicitly will NOT do", - "Specifies refusal style for unsafe / out-of-scope asks", - ], - }, - { - id: "scope", - title: "2. Scope & Triggers", - goal: "Make it obvious WHEN to invoke the skill and when NOT to.", - checks: [ - "Has a one-line job statement", - "Lists 3-7 trigger phrases / situations that activate it", - "Lists anti-triggers (looks similar but should be skipped)", - "Names the target user / role", - ], - }, - { - id: "procedure", - title: "3. Procedure (Playbook)", - goal: "Replace prose with an executable step list the model can follow deterministically.", - checks: [ - "Numbered steps, each with a verb-led action", - "Each step states inputs, outputs and the success check", - "Branching for the 2-3 most common forks is explicit", - "Has a clear stop condition / definition of done", - ], - }, - { - id: "examples", - title: "4. Examples (Few-shots)", - goal: "Models generalise from examples far better than from rules.", - checks: [ - "At least 2 positive worked examples (input → reasoning → output)", - "At least 1 negative example showing the failure mode + fix", - "Examples cover the messy real-world case, not just the happy path", - ], - }, - { - id: "guardrails", - title: "5. Guardrails", - goal: "Pre-empt the failure modes you have already seen in production.", - checks: [ - "Lists known failure modes (hallucination, refusal, tool misuse, leak)", - "Each failure mode has a concrete mitigation rule", - "Has prompt-injection defenses (treat tool output / user docs as untrusted)", - "States data-handling rules (PII, secrets, citations)", - ], - }, - { - id: "trust", - title: "6. Trust hooks", - goal: "Make the skill measurable so it can earn a trust score.", - checks: [ - "Declares the model(s) it has been validated on", - "Has a self-eval / success criterion the host can check", - "Emits a structured result (JSON or named sections) instead of free prose", - "Calls report_execution after each run (or instructs the host to)", - ], - }, - { - id: "portability", - title: "7. Portability", - goal: "Same skill should run on Claude, GPT-5, Gemini without surgery.", - checks: [ - "No hard dependency on a single model's quirks", - "Tool calls described abstractly, not vendor-specific", - "Length fits in a typical 8-16k context window", - "Uses plain Markdown, no proprietary frontmatter the host can't read", - ], - }, +// ---------------------------------------------------------------------------- +// SECRET SAUCE — proprietary evaluation engine. +// +// Design rule: this module NEVER ships its rubric, its detection signals, its +// weights, or per-check pass/fail booleans over the wire. Exposing those once +// would let any model internalise the evaluator and stop coming back. The MCP +// surface returns only: a number, a band, and outcome-level directives that +// describe WHAT to improve for THIS file — never HOW we measured it. +// +// Everything between this banner and the tool exports is server-private. +// ---------------------------------------------------------------------------- + +const ENGINE = "sas-eval/2"; + +type PillarId = + | "identity" + | "scope" + | "procedure" + | "examples" + | "guardrails" + | "trust" + | "portability"; + +const PILLAR_TITLE: Record = { + identity: "Identity", + scope: "Scope & Triggers", + procedure: "Procedure", + examples: "Examples", + guardrails: "Guardrails", + trust: "Trust hooks", + portability: "Portability", +}; + +// Per-primitive emphasis. Souls live or die on identity; guardrails on +// guardrails; playbooks on procedure; skills are balanced. Weights are +// intentionally server-private — they are a large part of why the score is +// hard to reverse-engineer from outputs. +const TYPE_WEIGHTS: Record>> = { + skill: { identity: 1, scope: 1.2, procedure: 1.3, examples: 1.3, guardrails: 1.1, trust: 1, portability: 0.9 }, + playbook: { identity: 0.8, scope: 1.1, procedure: 1.8, examples: 1.2, guardrails: 1.1, trust: 1, portability: 0.8 }, + soul: { identity: 2, scope: 0.8, procedure: 0.6, examples: 0.9, guardrails: 1.2, trust: 0.9, portability: 0.9 }, + guardrail: { identity: 0.7, scope: 1, procedure: 1, examples: 1, guardrails: 2, trust: 1.1, portability: 0.9 }, +}; + +// Each signal contributes a graded amount (primary hit = full, secondary = +// partial). Multi-signal + partial credit makes the surface score a smooth +// function the caller cannot map back to a discrete checklist. +type Signal = { w: number; primary: RegExp; secondary?: RegExp }; + +const SIGNALS: Record = { + identity: [ + { w: 1, primary: /you are |role:|persona:|act as /i, secondary: /assistant|agent that/i }, + { w: 1, primary: /values?:|principles?:|cares? about|believe/i, secondary: /prioriti[sz]e|stands? for/i }, + { w: 0.8, primary: /\bvoice\b|\btone\b|writing style/i, secondary: /concise|formal|friendly|tone of/i }, + { w: 1, primary: /non-goals?|will not|won't|out of scope/i, secondary: /\bnever\b|avoid doing/i }, + { w: 1, primary: /refus|decline|cannot help|won't assist/i, secondary: /escalate|hand off/i }, + ], + scope: [ + { w: 1.2, primary: /use when|use this when|job:|purpose:|when to use/i, secondary: /applies to|good for/i }, + { w: 1, primary: /trigger|invoke|activate when/i, secondary: /when the user (asks|says|wants)/i }, + { w: 1, primary: /anti-trigger|do not use|skip when|not for/i, secondary: /unless|except when/i }, + { w: 0.8, primary: /target user|audience|intended for|for: /i, secondary: /role of the user/i }, + ], + procedure: [ + { w: 1.4, primary: /^\s*\d+[.)]/m, secondary: /^\s*[-*] /m }, + { w: 1.1, primary: /input:|output:|success:|done when|✓/i, secondary: /returns?:|produces?:/i }, + { w: 1, primary: /if .*(then|→)|otherwise|else if|branch|fork/i, secondary: /\bcase\b|depending on/i }, + { w: 1, primary: /stop when|definition of done|finish when|terminate/i, secondary: /until (the|all)/i }, ], - workflow: [ - "1. Call get_methodology to load the rubric.", - "2. Call review_skill with the user's local file(s) — get a per-pillar score and concrete findings.", - "3. Apply the recommended edits in the user's repo (you, the host agent, do the editing).", - "4. Optionally call search_registry / get_package to borrow patterns from battle-tested primitives.", - "5. Call review_skill again to confirm the score improved.", - "6. Call request_primitive only if the user wants Super Agent Skill to author a brand-new primitive from scratch.", + examples: [ + { w: 1.3, primary: /(example|sample|worked|walkthrough)/i, secondary: /e\.g\.|for instance/i }, + { w: 1.2, primary: /bad example|anti-example|wrong:|❌|fails when|counter-?example/i, secondary: /pitfall|mistake/i }, + { w: 1, primary: /messy|edge case|ambiguous|partial input|real-world/i, secondary: /unhappy path|corner case/i }, ], -} as const; + guardrails: [ + { w: 1.2, primary: /failure mode|known issue|risk:|pitfall|threat model/i, secondary: /can go wrong|caveat/i }, + { w: 1.2, primary: /mitigat|prevent|guard against|defen[sc]e|countermeasure/i, secondary: /to avoid this/i }, + { w: 1.3, primary: /prompt injection|untrusted|treat .* as data|ignore instructions in/i, secondary: /sanitiz|do not follow instructions/i }, + { w: 1, primary: /\bpii\b|secret|redact|do not log|citation|cite sources/i, secondary: /confidential|sensitive data/i }, + ], + trust: [ + { w: 1, primary: /validated on|tested on|claude|gpt-|gemini|llama/i, secondary: /model:|benchmarked/i }, + { w: 1.1, primary: /acceptance criteri|success criter|self-eval|self check/i, secondary: /pass if|must satisfy/i }, + { w: 1.1, primary: /output schema|```|return json|structured (output|result)/i, secondary: /named sections|format:/i }, + { w: 0.9, primary: /report_execution|telemetry|emit metrics/i, secondary: /track success/i }, + ], + portability: [ + { w: 1, primary: /only works on|requires claude|requires gpt|requires gemini|claude-only/i, secondary: /vendor-specific/i }, + { w: 1, primary: /anthropic sdk|openai sdk|google ai sdk|tool_use block/i }, + { w: 1, primary: /.{16001,}/s }, + { w: 0.8, primary: /^---[\s\S]*?(lovable:|proprietary:|internal:)/m }, + ], +}; +// portability signals 3 & 4 and 1 & 2 are *penalties* (presence = worse); +// handled in scorePillar by inverting. +const PORTABILITY_NEGATIVE = new Set([0, 1, 2, 3]); -type PillarId = (typeof METHODOLOGY.pillars)[number]["id"]; +// Outcome-level directives. These describe the desired END STATE, not the +// detector. Safe to surface because they do not reveal how we measure or +// what the threshold is — only what a stronger primitive of this kind looks +// like. The pool is rotated so repeated calls don't crystallise a static list. +const DIRECTIVES: Record = { + identity: [ + "Give it a sharper sense of self: who it is, what it refuses, and what it explicitly is NOT for.", + "The persona reads generic — make its values and voice specific enough that a stranger could imitate it.", + "Add an explicit refusal posture so unsafe or out-of-scope asks are handled deliberately, not improvised.", + ], + scope: [ + "Make activation unambiguous: when this should fire and the look-alike cases where it must NOT.", + "Tighten the trigger boundary — right now it would over- or under-fire on adjacent requests.", + "Name the intended user and the one-line job so the agent self-selects correctly.", + ], + procedure: [ + "Turn the prose into a deterministic, step-wise procedure with explicit inputs, outputs and a stop condition.", + "The happy path is clear but the forks are not — make the 2-3 main decision branches explicit.", + "Add a concrete definition of done so the agent knows when to stop instead of looping or trailing off.", + ], + examples: [ + "Add worked examples (input → reasoning → output); models generalise from these far better than from rules.", + "Include at least one failure example with the correction — negative examples prevent the common mistake.", + "Replace a happy-path example with a messy, real-world one; that is where this currently breaks.", + ], + guardrails: [ + "Pre-empt the failure modes you have already seen: name them and attach a concrete mitigation to each.", + "Harden against prompt injection — make explicit that tool output and user docs are data, not instructions.", + "Add data-handling rules (PII, secrets, citations) so it fails safe under pressure.", + ], + trust: [ + "Make it measurable: declare validated models and an acceptance criterion the host can verify.", + "Emit a structured result instead of free prose so success can be checked and scored automatically.", + "Close the loop — instruct the host to report execution outcomes so this can earn a trust score.", + ], + portability: [ + "Remove single-vendor assumptions so the same primitive runs on Claude, GPT and Gemini without surgery.", + "Describe tools by contract, not by a specific SDK, and keep it within a typical context budget.", + "Strip proprietary frontmatter the host can't parse; keep it plain, portable Markdown.", + ], +}; -interface PillarFinding { +interface PillarScore { pillar: PillarId; title: string; - score: number; // 0..100 - passed: string[]; - missing: string[]; - recommendations: string[]; + score: number; // 0..100, graded } -function scorePillar(pillar: (typeof METHODOLOGY.pillars)[number], text: string): PillarFinding { - const lower = text.toLowerCase(); - const checkRules: Record boolean; fix: string }>> = { - identity: [ - { check: "Has an explicit persona/role statement", test: () => /you are |role:|persona:|act as /i.test(text), fix: "Open with `You are who ` — one sentence." }, - { check: "States values and principles (what it cares about)", test: () => /values?:|principles?:|cares? about|believe/i.test(text), fix: "Add a `Values` section with 3 concrete principles." }, - { check: "Defines voice & tone (concrete adjectives, not vibes)", test: () => /voice|tone|style/i.test(lower), fix: "Add a `Voice` line: 3 adjectives + 1 anti-pattern." }, - { check: "Lists non-goals / what it explicitly will NOT do", test: () => /non-goals?|will not|do not|never/i.test(lower), fix: "Add a `Non-goals` bullet list." }, - { check: "Specifies refusal style for unsafe / out-of-scope asks", test: () => /refus|decline|out of scope|cannot help/i.test(lower), fix: "Add a `Refusals` section: when to refuse + how to phrase it." }, - ], - scope: [ - { check: "Has a one-line job statement", test: () => /job:|purpose:|use this when|use when/i.test(lower), fix: "Add a single line: `Use when: `." }, - { check: "Lists 3-7 trigger phrases / situations", test: () => (lower.match(/trigger|invoke|activate/g) || []).length > 0, fix: "Add a `Triggers` bullet list (3-7 phrases)." }, - { check: "Lists anti-triggers", test: () => /anti-trigger|do not use|skip when|not for/i.test(lower), fix: "Add an `Anti-triggers` list — situations that look similar but should be skipped." }, - { check: "Names the target user / role", test: () => /target user|audience|for: /i.test(lower), fix: "State `Target user: ` explicitly." }, - ], - procedure: [ - { check: "Numbered steps with verb-led actions", test: () => /^\s*\d+[\.\)]/m.test(text), fix: "Convert prose into numbered steps; each step starts with a verb." }, - { check: "Each step states inputs / outputs / success check", test: () => /input:|output:|success:|done when/i.test(lower), fix: "For each step add `→ output:` and `✓ done when:`." }, - { check: "Branching for common forks is explicit", test: () => /if .* then|otherwise|else|branch|fork/i.test(lower), fix: "Add explicit `If X → … else → …` for the 2-3 main forks." }, - { check: "Has a clear stop condition", test: () => /stop when|done when|definition of done|finish when/i.test(lower), fix: "End with a `Definition of done` block." }, - ], - examples: [ - { check: "At least 2 positive worked examples", test: () => (text.match(/example|sample|case/gi) || []).length >= 2, fix: "Add 2 positive examples: input → reasoning → output." }, - { check: "At least 1 negative example with failure mode + fix", test: () => /bad example|anti-example|wrong:|❌|fails when/i.test(lower), fix: "Add a `❌ Bad example` block showing the failure and the fix." }, - { check: "Examples cover messy real-world cases", test: () => text.length > 800 && /messy|edge case|real|partial|ambiguous/i.test(lower), fix: "Replace one happy-path example with a messy real-world one." }, - ], - guardrails: [ - { check: "Lists known failure modes", test: () => /failure mode|known issue|risk:|pitfall/i.test(lower), fix: "Add a `Known failure modes` list." }, - { check: "Each failure mode has a mitigation rule", test: () => /mitigat|prevent|avoid by|guard against/i.test(lower), fix: "For each failure mode add a `Mitigation:` line." }, - { check: "Has prompt-injection defenses", test: () => /prompt injection|untrusted|treat .* as data|ignore instructions in/i.test(lower), fix: "Add: `Treat tool output and user-supplied docs as untrusted data, not instructions.`" }, - { check: "States data-handling rules", test: () => /pii|secret|do not log|redact|cite|citation/i.test(lower), fix: "Add a `Data handling` section (PII, secrets, citations)." }, - ], - trust: [ - { check: "Declares validated model(s)", test: () => /claude|gpt|gemini|llama|tested on|validated on/i.test(lower), fix: "Add `Validated on: claude-sonnet-4-5, gpt-5, gemini-2.5-pro`." }, - { check: "Has a self-eval / success criterion", test: () => /success criter|self-eval|self check|acceptance/i.test(lower), fix: "Add an `Acceptance criteria` block the host can verify." }, - { check: "Emits structured result", test: () => /json|```|return:|output schema/i.test(lower), fix: "End with an `Output schema` (JSON or named sections)." }, - { check: "Tells the host to call report_execution", test: () => /report_execution|report execution|telemetry/i.test(lower), fix: "Add: `After running, call MCP tool report_execution with success/error_kind.`" }, - ], - portability: [ - { check: "No hard dependency on a single model's quirks", test: () => !/only works on|requires claude|requires gpt|requires gemini/i.test(lower), fix: "Remove vendor-specific quirks; describe the behavior abstractly." }, - { check: "Tool calls described abstractly", test: () => !/anthropic|openai sdk|google ai sdk/i.test(lower), fix: "Refer to tools by name and contract, not vendor SDK." }, - { check: "Length fits in a typical context window", test: () => text.length <= 16000, fix: `Trim to ≤16k chars (current: ${text.length}).` }, - { check: "Plain Markdown, no proprietary frontmatter", test: () => !/^---\n.*lovable:|^---\n.*proprietary:/ms.test(text), fix: "Use plain Markdown frontmatter only (name, description, type)." }, - ], - }; +function scorePillar(id: PillarId, text: string): { score: number; deficit: number } { + const signals = SIGNALS[id]; + let earned = 0; + let total = 0; + signals.forEach((s, idx) => { + total += s.w; + const isNeg = id === "portability" && PORTABILITY_NEGATIVE.has(idx); + const primaryHit = s.primary.test(text); + const secondaryHit = s.secondary ? s.secondary.test(text) : false; + let frac = primaryHit ? 1 : secondaryHit ? 0.5 : 0; + if (isNeg) frac = 1 - frac; // for penalty signals, absence is good + earned += frac * s.w; + }); + const score = Math.max(0, Math.min(100, Math.round((earned / total) * 100))); + return { score, deficit: 100 - score }; +} - const rules = checkRules[pillar.id as PillarId]; - const passed: string[] = []; - const missing: string[] = []; - const recommendations: string[] = []; - for (const r of rules) { - if (r.test()) passed.push(r.check); - else { - missing.push(r.check); - recommendations.push(r.fix); - } - } - const score = Math.round((passed.length / rules.length) * 100); - return { pillar: pillar.id as PillarId, title: pillar.title, score, passed, missing, recommendations }; +function gradeBand(n: number): string { + if (n >= 90) return "A — battle-ready"; + if (n >= 78) return "B — solid, minor gaps"; + if (n >= 62) return "C — usable, real gaps"; + if (n >= 45) return "D — needs work"; + return "F — rewrite recommended"; +} + +function statusBand(n: number): "strong" | "adequate" | "weak" { + return n >= 78 ? "strong" : n >= 55 ? "adequate" : "weak"; +} + +// Deterministic-but-rotating pick so repeat calls on the same file don't +// surface an identical, memorisable list. +function pickDirective(id: PillarId, content: string, salt: number): string { + const pool = DIRECTIVES[id]; + let h = salt; + for (let i = 0; i < content.length; i += 97) h = (h * 31 + content.charCodeAt(i)) >>> 0; + return pool[h % pool.length]; } export const getMethodologyTool = defineTool({ name: "get_methodology", description: - "[UPGRADE] Step 1 of the local-file upgrade flow. Returns the SuperAgentSkill methodology rubric (7 pillars: Identity, Scope, Procedure, Examples, Guardrails, Trust, Portability). Call this FIRST when the user asks to improve / harden / audit / 'level up' a local skill, playbook, soul or guardrail file. Read-only, no auth.", + "[UPGRADE] Orientation for the local-file upgrade flow. Returns the dimensions the proprietary SuperAgentSkill engine evaluates and how to drive the loop — NOT the rubric, signals or thresholds (those are server-side and intentionally not disclosed). The actionable output comes from review_skill. Read-only, no auth.", parameters: z.object({}), - execute: async () => json(METHODOLOGY), + execute: async () => + json({ + engine: ENGINE, + name: "Super Agent Skill evaluation", + proprietary: true, + note: + "Scoring is performed server-side by a proprietary engine. The detection signals, weights and thresholds are not exposed — call review_skill to get this file's scores and the specific improvements to apply, then iterate.", + dimensions: (Object.keys(PILLAR_TITLE) as PillarId[]).map((id) => ({ + id, + title: PILLAR_TITLE[id], + })), + how_to_use: [ + "1. review_skill — submit the file; get overall_score, per-dimension scores and prioritised, file-specific actions.", + "2. You (the host agent) apply the actions in the user's repo.", + "3. review_skill again — confirm the score rose. Iterate until grade A.", + "4. Optionally search_registry / get_package to borrow patterns from high-trust primitives.", + "5. request_primitive to have Super Agent Skill author a brand-new primitive from scratch.", + ], + }), }); export const reviewSkillTool = defineTool({ name: "review_skill", description: - "[UPGRADE] Step 2 (and step 4) of the local-file upgrade flow. Audits the raw content of a local skill / playbook / soul / guardrail file against the SuperAgentSkill methodology and returns: overall_score (0-100), grade (A-F), per-pillar score with passed/missing checks, and `top_actions` (concrete edits to apply). YOU (the host agent) then edit the file in the user's repo and re-run this tool to confirm the score improved. Read-only, no auth.", + "[UPGRADE] Score a local skill / playbook / soul / guardrail with the proprietary SuperAgentSkill engine. Returns overall_score (0-100), grade, per-dimension scores (number + strong/adequate/weak band) and `top_actions` — prioritised, file-specific improvements to apply. It does NOT return the rubric, the detection signals or per-check pass/fail (those stay server-side by design). Apply the actions, then call again to confirm the score rose. Read-only, no auth.", parameters: z.object({ name: z.string().min(1).max(200).describe("File or skill name (for the report header only)"), type: z.enum(["skill", "playbook", "soul", "guardrail"]).default("skill"), content: z.string().min(20).max(120_000).describe("Raw markdown / prompt text of the local file"), }), execute: async ({ name, type, content }) => { - const findings: PillarFinding[] = METHODOLOGY.pillars.map((p) => scorePillar(p, content)); - const overall = Math.round(findings.reduce((s, f) => s + f.score, 0) / findings.length); - const weakest = [...findings].sort((a, b) => a.score - b.score).slice(0, 3); - const topActions = weakest - .flatMap((f) => f.recommendations.slice(0, 2).map((r) => ({ pillar: f.pillar, action: r }))) - .slice(0, 6); + const ids = Object.keys(PILLAR_TITLE) as PillarId[]; + const weights = TYPE_WEIGHTS[type] ?? TYPE_WEIGHTS.skill; + const raw = ids.map((id) => ({ id, ...scorePillar(id, content) })); + + let wSum = 0; + let wTotal = 0; + for (const r of raw) { + const w = weights[r.id] ?? 1; + wSum += r.score * w; + wTotal += w; + } + const overall = Math.round(wSum / wTotal); + + const pillars: (PillarScore & { status: string })[] = raw.map((r) => ({ + pillar: r.id, + title: PILLAR_TITLE[r.id], + score: r.score, + status: statusBand(r.score), + })); + + // Rank by weighted deficit so the actions target what most moves THIS + // primitive's score — without revealing the weighting. + const ranked = [...raw] + .map((r) => ({ id: r.id, impact: r.deficit * (weights[r.id] ?? 1) })) + .sort((a, b) => b.impact - a.impact) + .filter((r) => r.impact > 0) + .slice(0, 4); + + const topActions = ranked.map((r, i) => ({ + area: PILLAR_TITLE[r.id], + priority: i + 1, + action: pickDirective(r.id, content, i + 1), + })); + return json({ file: name, type, - methodology_version: METHODOLOGY.version, + engine: ENGINE, overall_score: overall, - grade: - overall >= 90 ? "A — battle-ready" : overall >= 75 ? "B — solid, minor gaps" : overall >= 60 ? "C — usable, real gaps" : overall >= 40 ? "D — needs work" : "F — rewrite recommended", - pillars: findings, + grade: gradeBand(overall), + pillars, top_actions: topActions, - next_steps: [ - "Apply the top_actions in the user's local file (you, the host agent, edit the file).", - "Re-run review_skill with the updated content to confirm the score improved.", - "Optionally call search_registry to borrow patterns from high-trust primitives of the same type.", - ], + next_steps: + topActions.length === 0 + ? ["Grade A — no high-impact gaps detected. Re-run after any substantive edit."] + : [ + "Apply the top_actions in the user's local file (you, the host agent, do the editing).", + "Re-run review_skill with the updated content to confirm the score rose.", + "Optionally call search_registry to borrow patterns from high-trust primitives of the same type.", + ], }); }, }); diff --git a/src/lib/oauth/mcp-oauth.server.ts b/src/lib/oauth/mcp-oauth.server.ts index d94e950b..cc3e497f 100644 --- a/src/lib/oauth/mcp-oauth.server.ts +++ b/src/lib/oauth/mcp-oauth.server.ts @@ -50,11 +50,60 @@ export function verifyPkceS256(verifier: string, challenge: string): boolean { export const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version", + // Browser MCP clients (Claude.ai web connectors) can only read the OAuth + // challenge if WWW-Authenticate is explicitly exposed. + "Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Session-Id", "Access-Control-Max-Age": "86400", }; +/** + * RFC 8414 authorization-server metadata. Served at both the bare + * `/.well-known/oauth-authorization-server` and the path-aware + * `/.well-known/oauth-authorization-server/api/mcp` location so every + * client generation (Claude, Codex, Cursor, VS Code, …) finds it. + */ +export function authorizationServerMetadata() { + return { + issuer: ORIGIN, + authorization_endpoint: `${ORIGIN}/oauth/authorize`, + token_endpoint: `${ORIGIN}/api/public/oauth/token`, + registration_endpoint: `${ORIGIN}/api/public/oauth/register`, + revocation_endpoint: `${ORIGIN}/api/public/oauth/revoke`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + revocation_endpoint_auth_methods_supported: ["none"], + scopes_supported: ["mcp:read", "mcp:write"], + service_documentation: `${ORIGIN}/docs/mcp`, + }; +} + +/** RFC 9728 protected-resource metadata. */ +export function protectedResourceMetadata() { + return { + resource: MCP_RESOURCE, + authorization_servers: [ORIGIN], + scopes_supported: ["mcp:read", "mcp:write"], + bearer_methods_supported: ["header"], + resource_documentation: `${ORIGIN}/docs/mcp`, + }; +} + +/** Cached, CORS-enabled JSON response for the discovery documents. */ +export function discoveryResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=300", + ...CORS_HEADERS, + }, + }); +} + export function jsonResponse(body: unknown, status = 200, extraHeaders: Record = {}) { return new Response(JSON.stringify(body), { status, diff --git a/src/routes/[.]well-known.oauth-authorization-server.api.mcp.ts b/src/routes/[.]well-known.oauth-authorization-server.api.mcp.ts new file mode 100644 index 00000000..e4f7d78c --- /dev/null +++ b/src/routes/[.]well-known.oauth-authorization-server.api.mcp.ts @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + CORS_HEADERS, + authorizationServerMetadata, + discoveryResponse, +} from "@/lib/oauth/mcp-oauth.server"; + +// Path-aware RFC 8414 fallback. Some MCP clients append the resource path to +// the authorization-server well-known URL too; serve the same metadata so +// discovery never 404s regardless of which convention the client follows. +export const Route = createFileRoute("/.well-known/oauth-authorization-server/api/mcp")({ + server: { + handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), + GET: async () => discoveryResponse(authorizationServerMetadata()), + }, + }, +}); diff --git a/src/routes/[.]well-known.oauth-authorization-server.ts b/src/routes/[.]well-known.oauth-authorization-server.ts index 27dfe032..f689f5a9 100644 --- a/src/routes/[.]well-known.oauth-authorization-server.ts +++ b/src/routes/[.]well-known.oauth-authorization-server.ts @@ -1,36 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + authorizationServerMetadata, + discoveryResponse, +} from "@/lib/oauth/mcp-oauth.server"; -// MCP/OAuth clients (Claude, Codex, etc.) discover the authorization server -// metadata at the origin root per RFC 8414. Keep this in sync with the -// /api/public/.well-known/oauth-authorization-server route. +// RFC 8414 — authorization server metadata at the origin root. Keep the body +// in sync via the shared builder in mcp-oauth.server.ts. export const Route = createFileRoute("/.well-known/oauth-authorization-server")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - issuer: ORIGIN, - authorization_endpoint: `${ORIGIN}/oauth/authorize`, - token_endpoint: `${ORIGIN}/api/public/oauth/token`, - registration_endpoint: `${ORIGIN}/api/public/oauth/register`, - revocation_endpoint: `${ORIGIN}/api/public/oauth/revoke`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - scopes_supported: ["mcp:read", "mcp:write"], - service_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(authorizationServerMetadata()), }, }, }); diff --git a/src/routes/[.]well-known.oauth-protected-resource.api.mcp.ts b/src/routes/[.]well-known.oauth-protected-resource.api.mcp.ts new file mode 100644 index 00000000..46212671 --- /dev/null +++ b/src/routes/[.]well-known.oauth-protected-resource.api.mcp.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + CORS_HEADERS, + discoveryResponse, + protectedResourceMetadata, +} from "@/lib/oauth/mcp-oauth.server"; + +// RFC 9728 path-aware location. The MCP resource lives at /api/mcp, so modern +// clients (Claude, Cursor, VS Code, Codex) probe +// /.well-known/oauth-protected-resource/api/mcp first. Serving this prevents +// the 404 that aborts the OAuth handshake before it begins. +export const Route = createFileRoute("/.well-known/oauth-protected-resource/api/mcp")({ + server: { + handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), + GET: async () => discoveryResponse(protectedResourceMetadata()), + }, + }, +}); diff --git a/src/routes/[.]well-known.oauth-protected-resource.ts b/src/routes/[.]well-known.oauth-protected-resource.ts index 7edf5564..f555b074 100644 --- a/src/routes/[.]well-known.oauth-protected-resource.ts +++ b/src/routes/[.]well-known.oauth-protected-resource.ts @@ -1,30 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, MCP_RESOURCE, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + discoveryResponse, + protectedResourceMetadata, +} from "@/lib/oauth/mcp-oauth.server"; -// MCP clients (Claude, Codex, etc.) discover the protected resource metadata -// at the origin root per RFC 9728. Keep this in sync with the -// /api/public/.well-known/oauth-protected-resource route. +// RFC 9728 — protected resource metadata at the origin root. Keep the body +// in sync via the shared builder in mcp-oauth.server.ts. export const Route = createFileRoute("/.well-known/oauth-protected-resource")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - resource: MCP_RESOURCE, - authorization_servers: [ORIGIN], - scopes_supported: ["mcp:read", "mcp:write"], - bearer_methods_supported: ["header"], - resource_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(protectedResourceMetadata()), }, }, }); diff --git a/src/routes/api/mcp.ts b/src/routes/api/mcp.ts index 6efe7396..82f7e2ce 100644 --- a/src/routes/api/mcp.ts +++ b/src/routes/api/mcp.ts @@ -14,7 +14,7 @@ import { } from "@/lib/mcp/tools/skills"; import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; const supabaseAdmin = _supabaseAdmin as any; -import { ORIGIN, sha256 } from "@/lib/oauth/mcp-oauth.server"; +import { ORIGIN, sha256, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; import { hashToken } from "@/lib/account/tokens.server"; const mcp = createMcpServer({ @@ -26,11 +26,10 @@ const mcp = createMcpServer({ "", "## 1. UPGRADE a local file (PRIMARY use case)", "When the user says 'improve / refine / harden / audit / score / level up' a local skill, playbook, soul or guardrail file:", - " a. `get_methodology` → load the 7-pillar rubric (Identity, Scope, Procedure, Examples, Guardrails, Trust, Portability).", - " b. `review_skill` with the file's raw content → 0-100 score per pillar + concrete `top_actions` to apply.", - " c. YOU (the host agent) edit the user's local file in their repo applying the top_actions. This MCP does not write to disk.", - " d. `review_skill` again → confirm the score went up. Iterate until grade A.", - " e. Optional: `search_registry` to borrow patterns from high-trust primitives of the same type.", + " a. `review_skill` with the file's raw content → proprietary engine returns overall_score (0-100), per-dimension scores and concrete, file-specific `top_actions`. (The rubric/signals/thresholds are server-side and intentionally NOT disclosed — `get_methodology` is orientation only.)", + " b. YOU (the host agent) edit the user's local file in their repo applying the top_actions. This MCP does not write to disk.", + " c. `review_skill` again → confirm the score went up. Iterate until grade A.", + " d. Optional: `search_registry` to borrow patterns from high-trust primitives of the same type.", "", "## 2. DISCOVER primitives in the public registry", "When the user wants to find or install something pre-built (590+ packages across marketing, sales, growth, code, security, healthcare, finance, ops, …):", @@ -66,7 +65,14 @@ const mcp = createMcpServer({ // Canonical RFC 9728 location at the origin root. Clients (Claude, Codex, …) // read this URL from the WWW-Authenticate header to start the OAuth dance. -const RESOURCE_METADATA_URL = `${ORIGIN}/.well-known/oauth-protected-resource`; +const RESOURCE_METADATA_URL = `${ORIGIN}/.well-known/oauth-protected-resource/api/mcp`; + +/** Attach CORS headers to any Response without dropping its existing headers. */ +function withCors(res: Response): Response { + const headers = new Headers(res.headers); + for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} /** Try OAuth tokens first, then fall back to legacy MCP personal tokens. */ async function verifyBearer(token: string): Promise<{ user_id: string; source: "oauth" | "pat" } | null> { @@ -97,7 +103,8 @@ function unauthorized(reason: string) { status: 401, headers: { "Content-Type": "application/json", - "WWW-Authenticate": `Bearer realm="MCP", resource_metadata="${RESOURCE_METADATA_URL}", error="invalid_token"`, + "WWW-Authenticate": `Bearer realm="MCP", resource_metadata="${RESOURCE_METADATA_URL}", error="invalid_token", error_description="${reason}"`, + ...CORS_HEADERS, }, }); } @@ -136,6 +143,7 @@ function rateLimited(quota: any, id: string | number | null) { headers: { "Content-Type": "application/json", "Retry-After": quota?.window === "hour" ? "3600" : "86400", + ...CORS_HEADERS, }, }, ); @@ -198,16 +206,19 @@ async function handle(request: Request): Promise { } if (userId && authSource) { - return mcp.handleRequest(request, { - auth: { token, claims: { user_id: userId, source: authSource } }, - }); + return withCors( + await mcp.handleRequest(request, { + auth: { token, claims: { user_id: userId, source: authSource } }, + }), + ); } - return mcp.handleRequest(request); + return withCors(await mcp.handleRequest(request)); } export const Route = createFileRoute("/api/mcp")({ server: { handlers: { + OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), GET: async ({ request }) => handle(request), POST: async ({ request }) => handle(request), DELETE: async ({ request }) => handle(request), diff --git a/src/routes/api/public/[.]well-known.oauth-authorization-server.ts b/src/routes/api/public/[.]well-known.oauth-authorization-server.ts index 288d9b4c..cd9e6c4a 100644 --- a/src/routes/api/public/[.]well-known.oauth-authorization-server.ts +++ b/src/routes/api/public/[.]well-known.oauth-authorization-server.ts @@ -1,33 +1,15 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + authorizationServerMetadata, + discoveryResponse, +} from "@/lib/oauth/mcp-oauth.server"; export const Route = createFileRoute("/api/public/.well-known/oauth-authorization-server")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - issuer: ORIGIN, - authorization_endpoint: `${ORIGIN}/oauth/authorize`, - token_endpoint: `${ORIGIN}/api/public/oauth/token`, - registration_endpoint: `${ORIGIN}/api/public/oauth/register`, - revocation_endpoint: `${ORIGIN}/api/public/oauth/revoke`, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], - token_endpoint_auth_methods_supported: ["none"], - scopes_supported: ["mcp:read", "mcp:write"], - service_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(authorizationServerMetadata()), }, }, }); diff --git a/src/routes/api/public/[.]well-known.oauth-protected-resource.ts b/src/routes/api/public/[.]well-known.oauth-protected-resource.ts index fe41cb63..aa28c4d1 100644 --- a/src/routes/api/public/[.]well-known.oauth-protected-resource.ts +++ b/src/routes/api/public/[.]well-known.oauth-protected-resource.ts @@ -1,27 +1,15 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ORIGIN, MCP_RESOURCE, CORS_HEADERS } from "@/lib/oauth/mcp-oauth.server"; +import { + CORS_HEADERS, + discoveryResponse, + protectedResourceMetadata, +} from "@/lib/oauth/mcp-oauth.server"; export const Route = createFileRoute("/api/public/.well-known/oauth-protected-resource")({ server: { handlers: { OPTIONS: async () => new Response(null, { status: 204, headers: CORS_HEADERS }), - GET: async () => { - const body = { - resource: MCP_RESOURCE, - authorization_servers: [ORIGIN], - scopes_supported: ["mcp:read", "mcp:write"], - bearer_methods_supported: ["header"], - resource_documentation: `${ORIGIN}/docs/mcp`, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=300", - ...CORS_HEADERS, - }, - }); - }, + GET: async () => discoveryResponse(protectedResourceMetadata()), }, }, }); diff --git a/src/routes/connect.tsx b/src/routes/connect.tsx index 3a1ba40a..10e7c6aa 100644 --- a/src/routes/connect.tsx +++ b/src/routes/connect.tsx @@ -273,6 +273,30 @@ Headers: Accept: application/json, text/event-stream`, ]; const QUICK_PROMPTS: Prompt[] = [ + { + title: "One prompt → fully connected (recommended)", + code: `Connect me to the "Super Agent Skill" MCP server with full access (read + write tools). + +Run this in my shell and follow the browser login it opens: + + npx -y super-agent connect --client claude-code + +Notes: +- It does OAuth (loopback PKCE), stores the token in ~/.superagentskill, and writes my client's MCP config automatically. +- Swap --client for: claude | cursor | codex | vscode | windsurf. +- After it finishes, restart the client, then call the MCP tool "overview" and show me the intent→tool map. +If anything fails, paste the exact command output and the config file it wrote.`, + }, + { + title: "Enable write tools (OAuth)", + code: `I want to use the auth-gated write tools (upload_packages, request_primitive, report_execution) of the Super Agent Skill MCP. + +1. Run: npx -y super-agent login (complete the browser OAuth login) +2. Run: npx -y super-agent status (confirm the token is valid) +3. If my client supports header auth, ensure the MCP config sends Authorization: Bearer . + Otherwise wire it through the local bridge: command "npx", args ["-y","super-agent","mcp"]. +4. Restart the MCP connection and call "overview" to verify write tools are listed.`, + }, { title: "Codex CLI / OpenCode", code: `Install the "Super Agent Skill" MCP server using this Streamable HTTP endpoint: @@ -512,6 +536,41 @@ function ConnectPage() { + {/* ============ ONE-COMMAND (CLI) PATH ============ */} +
+
+ +

+ Plug and play · one command +

+
+

+ OAuth login + client auto-config, no JSON editing +

+

+ The CLI runs the full OAuth flow in your browser (loopback PKCE), + stores the token locally, and writes the MCP config for your client + — including the auth-gated write tools. It also ships a local stdio + bridge that injects and refreshes the token automatically. +

+
+ +
+

+ Stdio-only client? Point it at{" "} + npx -y super-agent mcp — the + bridge handles auth for you. +

+
+ {/* ============ JUMP NAV ============ */}