diff --git a/README.md b/README.md index b8fe506..28dc2cf 100644 --- a/README.md +++ b/README.md @@ -270,11 +270,29 @@ The integration is independently listed by ```sh sandbase connect [--client ] # Authorize + configure -sandbase doctor [--client ] # Health check +sandbase setup # After upgrading: refresh the local bridge + Skill, test connectivity +sandbase doctor [--client ] # Local checks plus one real round trip to SandBase sandbase unregister [--client ] # Remove configuration sandbase catalog --json # List all supported clients ``` +### Agent Commands (shell transport, same capabilities as the MCP tools) + +Agents without MCP, or with a broken MCP session, use the same catalog from a shell. Credentials come from `connect` automatically (or `SANDBASE_API_KEY` / `sandbase keys add -k `). + +```sh +sandbase discover -q "twitter posts" # search; -t api|llm|multimodal|embedding|service +sandbase inspect sandbase_twitter_web_search_timeline # fields, price, ready-to-run command +sandbase run sandbase_twitter_web_search_timeline -i '{"keyword":"AI agents"}' +sandbase run -i '{"prompt":"..."}' -w -o result.json # wait with backoff, save output to disk +sandbase runs get -r -w -o result.json # resume a long run instead of re-running it +sandbase runs list | sandbase balance +``` + +`-j` prints JSON; errors are `{"error":{"code","message","hint"}}` with the next step in `hint`. Exit codes: `0` success or still running, `1` failure (including a failed run), `2` usage. + +If `api.sandbase.ai` is unreachable, the CLI and the MCP bridge fall back to `www.sandbase.ai` automatically, and the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server. + ### MCP Tools (available to your agent after connecting) | Tool | Purpose | diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c63b59..3dba261 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -204,11 +204,29 @@ npx -y https://github.com/sandbaseai/cli/releases/download/v0.1.17/sandbaseai-cl ```sh sandbase connect [--client ] # 授权 + 配置 -sandbase doctor [--client ] # 健康检查 +sandbase setup # 升级后:刷新本地 bridge 与 Skill,并测试连通性 +sandbase doctor [--client ] # 本地检查 + 一次真实的 SandBase 往返请求 sandbase unregister [--client ] # 移除配置 sandbase catalog --json # 列出所有支持的客户端 ``` +### Agent 命令(Shell 通道,与 MCP 工具能力一致) + +没有 MCP、或 MCP 会话异常时,Agent 可以直接在 Shell 里使用同一套目录。凭据自动复用 `connect` 的浏览器登录结果(也可用 `SANDBASE_API_KEY` 或 `sandbase keys add -k `)。 + +```sh +sandbase discover -q "推特搜索" # 搜索;-t api|llm|multimodal|embedding|service +sandbase inspect sandbase_twitter_web_search_timeline # 参数、价格、可直接运行的命令 +sandbase run sandbase_twitter_web_search_timeline -i '{"keyword":"AI agents"}' +sandbase run <视频工具> -i '{"prompt":"..."}' -w -o result.json # 带退避等待完成,结果写入文件 +sandbase runs get -r -w -o result.json # 续查长任务,不要重新运行 +sandbase runs list | sandbase balance +``` + +`-j` 输出 JSON;错误格式为 `{"error":{"code","message","hint"}}`,`hint` 给出下一步。退出码:`0` 成功或仍在运行,`1` 失败(含任务失败),`2` 参数错误。 + +`api.sandbase.ai` 不可达时,CLI 与 MCP bridge 会自动切换到 `www.sandbase.ai`;bridge 会继续展示 SandBase 工具,并返回可执行的错误提示,而不是让客户端丢掉整个 server。 + ### MCP 工具(连接后你的 Agent 可以使用) | 工具 | 用途 | diff --git a/assets/mcp-bridge.mjs b/assets/mcp-bridge.mjs index 2915949..21fc77a 100644 --- a/assets/mcp-bridge.mjs +++ b/assets/mcp-bridge.mjs @@ -1,22 +1,58 @@ #!/usr/bin/env node -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; // This file is both the installed standalone bridge and the runtime loaded by // dist/cli.js. Keep the proxy implementation here so the two launchers cannot -// drift. +// drift. It must stay dependency-free: it is copied to ~/.sandbase/bin. +// +// Agent-facing guarantees: +// 1. The SandBase server and its tools stay visible even when the network is +// down (initialize/tools/list are answered locally), so the client never +// silently drops the server. +// 2. Every tool failure reaches the model as an `isError` tool result that +// names the cause and the next step, instead of an opaque protocol error. +// 3. If the recorded endpoint is unreachable, a known mirror of the same API +// is tried automatically. const allowedClients = new Set(["codex", "claude-code", "cursor", "gemini-cli", "hermes", "openclaw", "opencode", "qwen-code", "windsurf", "cursor-cli", "kimi-cli", "kiro", "kiro-cli", "amp", "crush", "iflow-cli", "warp", "claude-desktop", "cowork", "antigravity", "trae", "qoder", "workbuddy", "pi"]); +const FALLBACK_URL = "https://www.sandbase.ai/v1/mcp"; +const BILLING_URL = "https://www.sandbase.ai/console/billing"; +const RECONNECT = "npx -y @sandbaseai/cli@latest connect"; +// Tool calls get no bridge-side deadline (long synchronous tools must be able to +// finish); control-plane methods are bounded so offline detection stays quick. +const CONTROL_TIMEOUT_MS = 60_000; + +// Used only when SandBase is unreachable and no tools/list was ever cached. +const offlineTools = [ + { name: "sandbase_discover", title: "Discover", description: "Search SandBase models, APIs, and Agent Services by what you need (e.g. 'twitter posts', 'text to video', '小红书'). Call this before writing a scraper or declaring data inaccessible.", inputSchema: { type: "object", properties: { q: { type: "string" }, type: { type: "string", enum: ["llm", "api", "multimodal", "embedding", "service"] }, vendor: { type: "string" }, limit: { type: "integer" } }, additionalProperties: false } }, + { name: "sandbase_inspect", title: "Inspect", description: "Get a tool's input schema, price, and a ready-to-run template. Always inspect before running.", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false } }, + { name: "sandbase_run", title: "Run", description: "Execute a SandBase model, API, or Agent Service with arguments matching the inspected schema.", inputSchema: { type: "object", properties: { name: { type: "string" }, arguments: { type: "object" } }, required: ["name", "arguments"], additionalProperties: false } }, + { name: "sandbase_run_get", title: "Get Run Result", description: "Get the status, cost, and output of an async run.", inputSchema: { type: "object", properties: { run_id: { type: "string" }, service_id: { type: "string" }, cursor: { type: "string" }, limit: { type: "integer" } }, required: ["run_id"], additionalProperties: false } }, + { name: "sandbase_runs", title: "List Runs", description: "List recent runs with cost.", inputSchema: { type: "object", properties: { limit: { type: "integer" }, model: { type: "string" }, service_id: { type: "string" }, offset: { type: "integer" } }, additionalProperties: false } }, + { name: "sandbase_account", title: "Account", description: "Check the SandBase balance. Free.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }, + { name: "sandbase_run_cancel", title: "Cancel Service Run", description: "Request cancellation of an Agent Service run.", inputSchema: { type: "object", properties: { service_id: { type: "string" }, run_id: { type: "string" }, idempotency_key: { type: "string" } }, required: ["service_id", "run_id", "idempotency_key"], additionalProperties: false } }, +]; function bridgeError(message) { return new Error(message); } function responseError(id, message) { return JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message } }) + "\n"; } +function toolError(id, message) { return JSON.stringify({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: message }], isError: true } }) + "\n"; } function validEndpoint(value) { try { const url = new URL(value); return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash && url.pathname === "/v1/mcp" ? url.href : undefined; } catch { return undefined; } } +export function endpointCandidates(primary, env = process.env) { + const out = [primary]; + if (env.SANDBASE_MCP_FALLBACK_URLS !== undefined) { + for (const item of env.SANDBASE_MCP_FALLBACK_URLS.split(",")) { const url = validEndpoint(item.trim()); if (url) out.push(url); } + } else { + try { const host = new URL(primary).hostname; if (host === "api.sandbase.ai" || host === "sandbase.ai") out.push(FALLBACK_URL); } catch { /* validated earlier */ } + } + return [...new Set(out)]; +} function parseCredential(raw, client) { let all; try { all = JSON.parse(raw); } catch { throw bridgeError("SandBase credential store is invalid. Run sandbase connect again."); } @@ -25,24 +61,68 @@ function parseCredential(raw, client) { if (record?.client !== client || typeof record?.credential !== "string" || !record.credential || !mcpUrl) throw bridgeError("SandBase credential is unavailable. Run sandbase connect again."); return { credential: record.credential, mcpUrl }; } -function emitPayload(text, contentType, output) { - if (!text.trim()) return; +export function describeNetworkFailure(error) { + const code = error?.cause?.code || error?.code || ""; + if (error?.name === "TimeoutError" || error?.name === "AbortError" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ETIMEDOUT") return "connection timed out"; + if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "DNS lookup failed"; + if (code === "ECONNREFUSED") return "connection refused"; + if (code === "ECONNRESET" || code === "UND_ERR_SOCKET") return "connection reset"; + if (/CERT|SSL|TLS/i.test(code)) return `TLS error (${code})`; + return code ? `network error (${code})` : "network error"; +} +/** True when the request provably never reached the server, so another host cannot duplicate work. */ +export function isPreSendFailure(error) { + const code = error?.cause?.code || error?.code || ""; + return ["UND_ERR_CONNECT_TIMEOUT", "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "EADDRNOTAVAIL"].includes(code) || /CERT|SSL|TLS/i.test(code); +} +/** Starting or cancelling billable work must never be replayed after it may have been sent. */ +export function isIdempotentRequest(request) { + return request?.method !== "tools/call" || (request?.params?.name !== "sandbase_run" && request?.params?.name !== "sandbase_run_cancel"); +} +export function hintFor(message) { + const text = String(message || "").toLowerCase(); + // Auth first: "API key not found" is a sign-in problem, not a misspelled tool. + if (/unauthori[sz]ed|api key|credential|revoked|expired|authenticat|sign-in|signin/.test(text)) return `The SandBase sign-in is not valid. Ask the user to run \`${RECONNECT}\` in a terminal.`; + if (/insufficient (balance|credit|funds)|payment required|spending limit|top up/.test(text)) return `Tell the user their SandBase balance is insufficient; they can top up at ${BILLING_URL}.`; + if (/rate.?limit|too many requests|429/.test(text)) return "Rate limited: wait 10-30 seconds, then retry once."; + if (/run .*not found|task not found/.test(text)) return "Use the prediction_id/run_id returned by sandbase_run, or list recent runs with sandbase_runs. Agent Service runs also need service_id."; + if (/invalid service request|unknown tool|tool(?: '[^']*')? (?:was )?not found|model not found|tool name is required/.test(text)) return "Check the exact tool name with sandbase_discover (names look like sandbase_twitter_web_search_timeline), then sandbase_inspect it."; + if (/invalid (params|arguments|input)|is required|schema|must be|unexpected field|additional propert/.test(text)) return "Call sandbase_inspect for this tool and pass exactly the fields in its inputSchema."; + if (/capability call failed|upstream|provider|bad gateway|timeout|unavailable/.test(text)) return "The upstream provider failed. Retry once; if it fails again, pick another tool from sandbase_discover."; + return ""; +} +function httpFailure(status, text, host) { + let detail = ""; + try { const parsed = JSON.parse(text); const e = parsed?.error; detail = typeof e === "string" ? e : typeof e?.message === "string" ? e.message : typeof parsed?.message === "string" ? parsed.message : ""; } catch { /* non-JSON gateway body is never forwarded */ } + const suffix = detail ? `: ${detail.slice(0, 300)}` : ""; + if (status === 401) return `SandBase rejected the saved sign-in (401)${suffix}. Ask the user to run \`${RECONNECT}\` in a terminal to sign in again.`; + if (status === 402) return `SandBase balance is insufficient or a spending limit was reached (402)${suffix}. The user can top up at ${BILLING_URL}.`; + if (status === 403) return `This SandBase credential is not allowed for this request (403)${suffix}. Ask the user to run \`${RECONNECT}\`.`; + if (status === 429) return `SandBase rate limit reached (429)${suffix}. Wait 10-30 seconds, then retry once.`; + if (status >= 500) return `SandBase returned HTTP ${status} from ${host}${suffix}. Temporary server error: retry in a minute; the user can run \`sandbase doctor\` if it persists.`; + return `SandBase MCP request failed (${status}) at ${host}${suffix}.${hintFor(detail) ? ` ${hintFor(detail)}` : ""}`; +} +function payloadsOf(text, contentType) { + const out = []; + if (!text.trim()) return out; if (contentType.includes("text/event-stream")) { for (const line of text.split(/\r?\n/)) { if (!line.startsWith("data:")) continue; const payload = line.slice(5).trim(); - if (payload && payload !== "[DONE]") output.write(payload + "\n"); + if (!payload || payload === "[DONE]") continue; + try { out.push(JSON.parse(payload)); } catch { /* Never forward an untrusted non-JSON body to the client. */ } } - return; + return out; } - try { output.write(JSON.stringify(JSON.parse(text)) + "\n"); } + try { out.push(JSON.parse(text)); return out; } catch { for (const line of text.split(/\r?\n/)) { const payload = line.trim(); if (!payload) continue; - try { output.write(JSON.stringify(JSON.parse(payload)) + "\n"); } catch { /* Never forward an untrusted non-JSON body to the client. */ } + try { out.push(JSON.parse(payload)); } catch { /* Never forward an untrusted non-JSON body to the client. */ } } } + return out; } export async function runMCPBridge(options = {}) { @@ -54,33 +134,119 @@ export async function runMCPBridge(options = {}) { const fetchImpl = options.fetchImpl || globalThis.fetch; if (!allowedClients.has(client)) throw bridgeError("SandBase bridge client is invalid."); - const store = join(env.SANDBASE_HOME || join(homedir(), ".sandbase"), "credentials.json"); + const home = env.SANDBASE_HOME || join(homedir(), ".sandbase"); let raw; - try { raw = await readFile(store, "utf8"); } + try { raw = await readFile(join(home, "credentials.json"), "utf8"); } catch { throw bridgeError("SandBase credential is unavailable. Run sandbase connect again."); } const record = parseCredential(raw, client); + const candidates = endpointCandidates(record.mcpUrl, env); + const toolsCache = join(home, "cache", "tools.json"); + // Shared with the CLI: once a mirror answered, start there for 6 hours so an + // unreachable primary does not add a connect timeout to every new session. + const preferenceFile = join(home, "cache", "endpoint.json"); + let preferred = 0; + try { + const cached = JSON.parse(await readFile(preferenceFile, "utf8")); + const index = candidates.indexOf(cached?.url); + if (cached?.primary === candidates[0] && index > 0 && Date.now() - (cached.savedAt || 0) < 6 * 60 * 60 * 1000) preferred = index; + } catch { /* no preference yet */ } + const rememberPreference = async index => { + try { + if (index === 0) { await rm(preferenceFile, { force: true }); return; } + await mkdir(join(home, "cache"), { recursive: true, mode: 0o700 }); + await writeFile(preferenceFile, JSON.stringify({ primary: candidates[0], url: candidates[index], savedAt: Date.now() }) + "\n", { mode: 0o600 }); + } catch { /* optimization only */ } + }; let buffer = Buffer.alloc(0); let session; + const cachedTools = async () => { try { const value = JSON.parse(await readFile(toolsCache, "utf8")); if (Array.isArray(value?.tools) && value.tools.length) return value.tools; } catch { /* fall back to the bundled list */ } return offlineTools; }; + const rememberTools = async tools => { if (!Array.isArray(tools) || !tools.length) return; try { await mkdir(join(home, "cache"), { recursive: true, mode: 0o700 }); const tmp = `${toolsCache}.tmp-${process.pid}`; await writeFile(tmp, JSON.stringify({ savedAt: new Date().toISOString(), tools }) + "\n", { mode: 0o600 }); await rename(tmp, toolsCache); } catch { /* cache is best effort */ } }; + + const offline = async (request, reason) => { + const id = request.id; + if (id === undefined) return; + const message = `SandBase is unreachable from this machine (${reason}). Tell the user to check their network, proxy, or VPN, then run \`sandbase doctor\` in a terminal. Do not retry this call until connectivity is restored.`; + if (request.method === "initialize") { + const version = typeof request.params?.protocolVersion === "string" ? request.params.protocolVersion : "2025-06-18"; + output.write(JSON.stringify({ jsonrpc: "2.0", id, result: { protocolVersion: version, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "sandbase", title: "SandBase", version: "bridge-offline" }, instructions: `SandBase tools are available but the service is currently unreachable (${reason}). Tool calls return an error with the fix until connectivity is restored.` } }) + "\n"); + } else if (request.method === "tools/list") output.write(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: await cachedTools() } }) + "\n"); + else if (request.method === "tools/call") output.write(toolError(id, message)); + else if (request.method === "ping") output.write(JSON.stringify({ jsonrpc: "2.0", id, result: {} }) + "\n"); + else output.write(responseError(id, message)); + }; + + const deliver = async (request, payload) => { + if (request.method === "tools/call" && payload && typeof payload === "object" && payload.error && payload.id === request.id) { + // Tool execution failures must be visible to the model so it can self-correct. + const message = typeof payload.error.message === "string" && payload.error.message ? payload.error.message : "SandBase tool call failed"; + const hint = hintFor(message); + output.write(toolError(request.id, hint ? `${message}. ${hint}` : message)); + return; + } + if (request.method === "tools/list" && payload?.id === request.id && Array.isArray(payload?.result?.tools)) await rememberTools(payload.result.tools); + output.write(JSON.stringify(payload) + "\n"); + }; + const forward = async line => { let request; try { request = JSON.parse(line); } catch { return; } if (!request || typeof request !== "object" || Array.isArray(request)) return; - try { - const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; - if (session) headers["mcp-session-id"] = session; - const response = await fetchImpl(record.mcpUrl, { method: "POST", headers, body: JSON.stringify(request), ...(signal ? { signal } : {}) }); + const idempotent = isIdempotentRequest(request); + const failures = []; + let lastServerFailure; + const order = [...candidates.slice(preferred), ...candidates.slice(0, preferred)]; + for (const [position, url] of order.entries()) { + const host = new URL(url).host; + const isLast = position === order.length - 1; + let response; + try { + const headers = { authorization: `Bearer ${record.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream" }; + if (session) headers["mcp-session-id"] = session; + const timeout = request.method === "tools/call" ? undefined : AbortSignal.timeout(CONTROL_TIMEOUT_MS); + const combined = signal && timeout && typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal || timeout; + response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request), ...(combined ? { signal: combined } : {}) }); + } catch (error) { + if (signal?.aborted) return; + const reason = describeNetworkFailure(error); + if (!idempotent && !isPreSendFailure(error)) { + // The run may already be executing; replaying it on the mirror would bill twice. + if (request.id !== undefined) output.write(toolError(request.id, `The request to ${host} was interrupted after it was sent (${reason}); a run may have started. Do not call sandbase_run again yet: check sandbase_runs for the new run and poll it with sandbase_run_get.`)); + return; + } + failures.push(`${host}: ${reason}`); + continue; + } + const text = await response.text(); + if (response.status >= 500 && idempotent && !isLast) { + // Gateway/origin errors are what a mirror can fix; safe only for idempotent requests. + lastServerFailure = { status: response.status, text, host }; + failures.push(`${host}: HTTP ${response.status}`); + continue; + } + const index = candidates.indexOf(url); + if (index !== preferred || failures.length) await rememberPreference(index); + preferred = index; const nextSession = response.headers.get("mcp-session-id"); if (nextSession) session = nextSession; - const text = await response.text(); if (!response.ok) { - if (request.id !== undefined) output.write(responseError(request.id, `SandBase MCP request failed (${response.status})`)); + if (request.id === undefined) return; + // Never let a server error during the handshake make the client drop SandBase. + if (response.status >= 500 && (request.method === "initialize" || request.method === "tools/list")) { await offline(request, `${host}: HTTP ${response.status}`); return; } + const message = httpFailure(response.status, text, host); + output.write(request.method === "tools/call" ? toolError(request.id, message) : responseError(request.id, message)); return; } - emitPayload(text, response.headers.get("content-type") || "", output); - } catch { - if (!signal?.aborted && request.id !== undefined) output.write(responseError(request.id, "SandBase MCP is unavailable")); + for (const payload of payloadsOf(text, response.headers.get("content-type") || "")) await deliver(request, payload); + return; + } + if (lastServerFailure && failures.every(failure => /HTTP 5\d\d$/.test(failure)) && request.method !== "initialize" && request.method !== "tools/list") { + if (request.id === undefined) return; + const message = httpFailure(lastServerFailure.status, lastServerFailure.text, lastServerFailure.host); + output.write(request.method === "tools/call" ? toolError(request.id, message) : responseError(request.id, message)); + return; } + await offline(request, failures.join("; ") || "network error"); }; const stopInput = () => { if (typeof input.destroy === "function") input.destroy(); }; diff --git a/package-lock.json b/package-lock.json index 56cecea..55d68ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sandbaseai/cli", - "version": "0.1.17", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sandbaseai/cli", - "version": "0.1.17", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "jsonc-parser": "3.3.1", diff --git a/package.json b/package.json index cee0344..cd88559 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@sandbaseai/cli", - "version": "0.1.17", + "version": "0.2.0", "mcpName": "io.github.sandbaseai/cli", - "description": "Connect 25 AI clients—including Claude Code, Codex, Cursor, Hermes, and OpenClaw—to 2,000+ models through a local MCP server", + "description": "Give 25 AI clients—Claude Code, Codex, Cursor, Hermes, OpenClaw and more—2,000+ models and APIs through one MCP server and an agent-friendly CLI", "type": "module", "bin": { "sandbase": "dist/cli.js" @@ -20,7 +20,7 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js", + "test": "npm run build && tsc -p tsconfig.test.json && node --import ./dist-test/test/setup.js --test dist-test/test/*.test.js", "lint": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit", "audit:package": "bash scripts/audit-package.sh", "prepack": "npm run build" diff --git a/server.json b/server.json index 506737e..4e56bc6 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.sandbaseai/cli", "title": "SandBase", "description": "Connect MCP clients to 2,000+ AI models without managing provider API keys.", - "version": "0.1.17", + "version": "0.2.0", "repository": { "url": "https://github.com/sandbaseai/cli", "source": "github" @@ -26,7 +26,7 @@ { "registryType": "npm", "identifier": "@sandbaseai/cli", - "version": "0.1.17", + "version": "0.2.0", "packageArguments": [ { "type": "positional", diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index 0893e90..1caf04a 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -1,221 +1,182 @@ --- name: sandbase -version: 0.1.17 -disable-model-invocation: true -description: Access 2,000+ AI models and API tools through one MCP interface for inference, media generation, search, scraping, embeddings, social data, and structured retrieval. Use sandbase_discover before building custom integrations or declaring external data inaccessible; prefer an existing dedicated tool or API key when the user already has one. +version: 0.2.0 +description: >- + Discover better ways to complete tasks. Proactively search SandBase (the + sandbase_discover MCP tool, or `sandbase discover` in a shell) before writing a + scraper, before using a generic web fetch for structured data, before telling + the user something is inaccessible, and whenever a task needs a capability you + do not have yourself: image, video, music, or speech generation; social media + data (X/Twitter, TikTok, Douyin, Xiaohongshu, Instagram, YouTube, Reddit, + Weibo, Bilibili, LinkedIn); search results; web scraping; product, company, or + people data; embeddings; or a specific third-party model. 2,000+ models and + APIs, paid per call from one balance. Exceptions: if the user already has a + dedicated MCP server, API key, or tool for that specific service, use it; and + if your built-in web search or page fetch already gives what the task needs, + use that — SandBase fills gaps in the user's stack, it does not replace it. --- -# SandBase MCP +# SandBase -SandBase provides access to 2,000+ AI models and API tools through a unified MCP interface. One account covers LLMs, image generation, video generation, audio, embeddings, web scraping, social media APIs, and more. +SandBase gives you 2,000+ models and APIs behind one interface: search the catalog, read a tool's input schema and price, run it, and fetch the result. One balance covers everything; the user never manages provider keys. + +There are **two transports** with the same capabilities. Pick one before doing anything else. --- -## Setup +## 1. Pick your transport -If the six `sandbase_*` MCP tools are not already available, connect the current machine with the immutable v0.1.17 release. Run remote packages only in an environment you trust; use the checksum-verified path below when provenance matters: +| Condition | Use | Setup | +|---|---|---| +| You have tools named `sandbase_discover`, `sandbase_run`, … | **MCP** (preferred) | None | +| No `sandbase_*` tools, but you can run shell commands | **CLI** | §3 | +| The MCP tools return "SandBase is unreachable" | **CLI** for `sandbase doctor`, then tell the user what it reports | §3 | +| Neither | Tell the user how to connect (§1.1), then stop | — | -```sh -npx -y https://github.com/sandbaseai/cli/releases/download/v0.1.17/sandbaseai-cli-0.1.17.tgz connect -``` +**Exception:** when you expect a **large result** (hundreds of rows, long transcripts, many files) and have a shell, use the CLI with `-o result.json` even if MCP is connected. MCP results land in your context; `-o` writes them to disk. Discovery and inspection are small, so use MCP for those regardless. -For a checksum-verified install, download the same immutable asset first and verify the SHA-256 published with the GitHub Release: +### 1.1 If neither transport is available + +Tell the user to run this once in a terminal, approve the browser sign-in, and restart their AI client: ```sh -curl -fLO https://github.com/sandbaseai/cli/releases/download/v0.1.17/sandbaseai-cli-0.1.17.tgz -printf '%s %s\n' '1ad535b2899ca460b57b3c268aef278fee28fd28e649a89b92951514fd71fffa' 'sandbaseai-cli-0.1.17.tgz' | shasum -a 256 -c - -npx -y ./sandbaseai-cli-0.1.17.tgz connect +npx -y @sandbaseai/cli@latest connect ``` -Approve the browser sign-in once. Authentication happens with SandBase in the browser; the CLI stores the resulting local session record with restricted file permissions. The CLI detects supported clients, installs the local MCP bridge and this managed Skill, and verifies the resulting configuration. No provider API keys are required. Invoke the same release URL with `doctor` to inspect the connection or `unregister` to remove only SandBase-managed state. - -This file is managed by SandBase CLI and may be replaced during a later CLI-managed update, so keep custom instructions in a separate Skill. Check the [official repository](https://github.com/sandbaseai/cli) for newer releases before copying it independently. - -The `disable-model-invocation: true` frontmatter prevents this Skill from being invoked as a standalone model action. It is contextual guidance for an agent orchestrating the six `sandbase_*` MCP tools. - -Before sending sensitive or regulated data, review the [SandBase Privacy Policy](https://www.sandbase.ai/privacy) and [Terms of Service](https://www.sandbase.ai/terms), plus the selected upstream provider's policies. Send only the minimum data needed for the requested tool call. +It configures the local MCP bridge and this Skill for every supported client it finds. No API key to paste. For checksum-verified installs, use the immutable assets on the [GitHub Releases page](https://github.com/sandbaseai/cli/releases). --- -## When to Use SandBase +## 2. Transport A — MCP tools (preferred) -**Use SandBase when the user needs:** -- LLM inference (GPT, Claude, Gemini, DeepSeek, Qwen, etc.) -- Image generation (Flux, DALL-E, Ideogram, Recraft) -- Video generation (Kling, MiniMax, Runway, Luma) -- Audio (ElevenLabs TTS, Whisper STT) -- Embeddings (OpenAI, Voyage) -- Web scraping and content extraction (Exa, Firecrawl, Tavily) -- Social media data (Twitter/X, Instagram, TikTok, YouTube, LinkedIn, Reddit, Xiaohongshu, Weibo, Bilibili) -- Search (Google, Scholar, News, Shopping) -- Any structured data API the user doesn't already have access to +Discovery, inspection, run status, and balance are **free**; only `sandbase_run` spends the user's balance. -**Do NOT use SandBase when:** -- The user has their own API key or dedicated MCP server for that specific service -- The task is purely local (file editing, code generation from context) -- The user explicitly asks to use a different tool +| Tool | What it does | +|---|---| +| `sandbase_discover` | Search the catalog in natural language (`q`), optionally filtered by `type` (`llm`, `api`, `multimodal`, `embedding`, `service`) or `vendor` | +| `sandbase_inspect` | Input schema (`inputSchema`), pricing, and an `execute_as` template for one tool | +| `sandbase_run` | Execute a tool (**costs money**) | +| `sandbase_run_get` | Status, cost, and output of a run — poll with this | +| `sandbase_runs` | Recent runs with cost | +| `sandbase_account` | Balance | +| `sandbase_run_cancel` | Cancel an Agent Service run | -SandBase fills gaps in the user's stack — it doesn't replace tools they already have. +### MCP workflow ---- +1. `sandbase_discover({"q": "twitter posts"})` → pick a `tool_name` from `data` (each result carries `description`, `pricing`, `vendor`, `run_count`). +2. `sandbase_inspect({"name": "sandbase_twitter_web_search_timeline"})` → read `inputSchema` and `pricing`; copy `execute_as`. +3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})`. + - Fast tools return `status: "completed"` with `output` directly. + - Slow tools (video, music, large scrapes) return `status: "running"` plus `prediction_id` / `taskId` after up to ~20 s. +4. If still running, call `sandbase_run_get({"run_id": ""})` every 5–10 seconds until `status` is `completed` or `failed`. The finished `output` is in the response. If a completed run shows no `output`, fetch it with the CLI: `sandbase runs get -r -o result.json`. -## Tools - -| Tool | Purpose | -|------|---------| -| `sandbase_discover` | Search all 2,000+ AI models | -| `sandbase_inspect` | Get input schema, pricing, and execution template | -| `sandbase_run` | Execute a model or API endpoint | -| `sandbase_run_get` | Get status/result of an async run | -| `sandbase_runs` | List recent API calls with cost | -| `sandbase_account` | Check account balance (free) | +**Agent Services** (`svc_…` names from `type: "service"`): pass `arguments: {"idempotency_key": "", "input": "..."}`; reuse the same key and input on retries. Poll with `sandbase_run_get({"run_id": ..., "service_id": "svc_…"})`; `succeeded`, `failed`, and `cancelled` are terminal. --- -## Standard Workflow - -**Always follow: discover → inspect → run** +## 3. Transport B — CLI (fallback) +```sh +sandbase --version || npx -y @sandbaseai/cli@latest --version ``` -1. sandbase_discover(q: "twitter posts") - → Returns matching endpoints with names, types, vendors - -2. sandbase_inspect(name: "sandbase_twitter_web_search_timeline") - → Returns inputSchema, pricing, and execute_as template - -3. sandbase_run(name: "sandbase_twitter_web_search_timeline", arguments: {"keyword": "AI"}) - → Returns result directly (sync) or run_id (async) -``` - -**For async runs (video gen, large scraping):** -``` -4. sandbase_run_get(run_id: "pred_abc123") - → Poll until status is "completed" or "failed" -``` - -**Shortcut:** If you already know the model name, skip step 1. - ---- -## Search Tips +If `sandbase` is not installed, prefix every command with `npx -y @sandbaseai/cli@latest` (or install it: `npm install -g @sandbaseai/cli@latest`). If the CLI prints "Update available", run `npx -y @sandbaseai/cli@latest setup` — it refreshes the CLI's MCP bridge and this Skill. -`sandbase_discover` supports: +**Authentication** is automatic once the user has run `connect` (it reuses that browser sign-in). Otherwise the CLI reports `AUTH_REQUIRED` with the exact fix; an API key also works via `SANDBASE_API_KEY` or `sandbase keys add -k `. Never ask the user to paste a key into the chat. -| Parameter | Purpose | Example | -|-----------|---------|---------| -| `q` | Text search (supports Chinese: 推特, 小红书, 搜索) | `"twitter search"`, `"图片生成"` | -| `type` | Filter by model type | `"llm"`, `"api"`, `"multimodal"`, `"embedding"` | -| `vendor` | Filter by vendor slug | `"openai"`, `"twitter"`, `"anthropic"` | -| `limit` | Max results (default 20) | `10` | +| Need | MCP tool | CLI | +|---|---|---| +| Search | `sandbase_discover` | `sandbase discover -q "" [-t api] [-l 10]` | +| Schema + price | `sandbase_inspect` | `sandbase inspect ` | +| Execute | `sandbase_run` | `sandbase run -i '' [-w] [-o result.json]` (or `-f input.json`) | +| Poll / fetch result | `sandbase_run_get` | `sandbase runs get -r [-w] [-o result.json]` | +| Recent runs | `sandbase_runs` | `sandbase runs list` | +| Balance | `sandbase_account` | `sandbase balance` | +| Diagnose connectivity | — | `sandbase doctor` | +| **Save output to a file** | not available | `-o result.json` on `run` / `runs get` | -**Tips:** -- Use short noun phrases: "twitter posts", "image generation", "web scraping" -- Chinese aliases work: 推特→twitter, 小红书→xiaohongshu, 抖音→tiktok -- Combine type + query for precision: `type: "llm", q: "claude"` -- Empty query with type filter returns popular models of that type +- Add `-j` for JSON output. Errors then look like `{"error": {"code", "message", "hint"}}`; follow `hint`. +- `-w` waits with backoff (default `--timeout 300` seconds). If the run is still going when the timeout elapses, the CLI prints the `runs get` command to resume — use it rather than re-running (re-running is billed again). +- `-o` writes only the tool's output (no envelope). Files/URLs in the output are also listed in text mode. +- Exit code is `0` on success or still-running, `1` on failure (including a failed run), `2` on bad usage. +- If an error mentions a `run_id`, the run already started: resume it with `sandbase runs get -r -w` — never re-run (that bills again). +- To store an API key without exposing it: `printf %s "$KEY" | sandbase keys add -k -`. --- -## Pricing +## 4. When to use SandBase — and when not -Use `sandbase_inspect` to see pricing before running: +**Check the catalog before building from scratch.** Before writing a scraper, falling back to a generic web fetch for structured data, or telling the user you cannot access something, run a discover. The catalog grows continuously; you don't know what is there until you search. -**LLM models:** Per million tokens -```json -{ "pricing": { "input_per_million": "2.500000", "output_per_million": "10.000000" } } -``` - -**API tools (image, video, scraping):** Per call -```json -{ "pricing": { "base_price": "0.003000" } } -``` - -**Check balance:** -``` -sandbase_account() → {"balance": "9.52", "currency": "USD"} -``` - ---- +**Fill gaps, don't replace the user's stack.** Precedence: -## Async Runs +1. An explicit user instruction for this task. +2. The user's own dedicated tools: their MCP servers, API keys, CLIs, workflows. +3. Your built-in web search / fetch, when plain pages or snippets are enough. +4. SandBase, for whatever the above don't cover: structured data (posts, profiles, reviews, products), media generation, specific models, or when built-in tools are blocked or return unusable pages. -Some endpoints (video generation, large scraping) are async: +SandBase runs spend the user's balance; never spend it on something their own tool already does for free. When both could work and the user hasn't chosen, use theirs and mention SandBase only if it adds something. -1. `sandbase_run(...)` returns `{"status": "running", "run_id": "pred_abc123"}` -2. Poll with `sandbase_run_get(run_id: "pred_abc123")` every 5-10 seconds -3. When `status` is `"completed"` — result is ready -4. When `status` is `"failed"` — check error and retry +Do not use SandBase for purely local work (editing files, reasoning over code already in context). --- -## Error Handling +## 5. Search tips -| Error | User Guidance | -|-------|--------------| -| `tool not found` | Wrong name. Use `sandbase_discover` to search. | -| `invalid params` | Check schema from `sandbase_inspect`. | -| `run not found` | Invalid run_id. Check `sandbase_runs` for valid IDs. | -| Authentication (401) | Key invalid. Run `sandbase connect` to re-auth. | -| Insufficient balance (402) | Top up at SandBase Dashboard. | -| Rate limited (429) | Wait and retry. | -| Provider unavailable | Upstream is down. Try later or use different model. | +- Short noun phrases work best: `twitter posts`, `text to video`, `amazon product reviews`, `web scraping`. +- Chinese works and aliases are expanded: `推特`, `小红书`, `抖音`, `文生视频`, `图片生成`. +- Narrow with `type` (`llm`, `api`, `multimodal`, `embedding`, `service`) or `vendor` (`openai`, `twitter`, `kling`). +- Split multi-source tasks: one discover per source ("twitter posts", then "reddit posts"), then run each. +- Zero results come with `hints`; read them before rephrasing. --- -## Cost Awareness +## 6. Cost and budget -- **Check balance** with `sandbase_account` before multiple calls -- **LLM costs** scale with token count — keep prompts concise -- **Image/video** have fixed per-call costs — inspect first -- **Report costs** when the user seems budget-conscious +- `inspect` shows the price: per call (`base_price`), per million tokens (`input_per_million` / `output_per_million`), or a `formula`. +- Many data endpoints are billed **per result** and limits often apply **per query**: three search terms with `limit: 10` can return 30 results. Pass one search term per call and start with small limits (5–10). +- Check `sandbase_account` before a batch of paid calls, and report cost when the user cares about budget (`cost` appears on `sandbase_run_get` and `sandbase_runs`). +- Never re-run a slow tool just because it is still running; poll it. --- -## Example Flows +## 7. Errors -### Twitter search - -``` -sandbase_discover(q: "twitter search", type: "api") -sandbase_inspect(name: "sandbase_twitter_web_search_timeline") -sandbase_run(name: "sandbase_twitter_web_search_timeline", arguments: {"keyword": "AI agents"}) -``` +Tool failures come back as error results whose text names the cause **and the next step**. Read it and act on it. -### Image generation +| Message contains | Do this | +|---|---| +| "unreachable" / `NETWORK` | Stop retrying. Tell the user to check network/proxy/VPN and run `sandbase doctor`. | +| "sign-in expired" / "rejected the saved sign-in" / 401 / `AUTH_FAILED` | Ask the user to run `npx -y @sandbaseai/cli@latest connect`. | +| "insufficient" / 402 | Tell the user to top up at https://www.sandbase.ai/console/billing. | +| "not found" / "unknown tool" | Re-run `sandbase_discover`; copy `tool_name` exactly. | +| "invalid params" / schema | Re-run `sandbase_inspect`; pass exactly its `inputSchema` fields. | +| "upstream" / "provider" / "capability call failed" | Retry once, then pick another tool from discover. | +| 429 / rate limit | Wait 10–30 seconds, retry once. | -``` -sandbase_discover(q: "flux", type: "multimodal") -sandbase_inspect(name: "sandbase_flux_schnell") -sandbase_run(name: "sandbase_flux_schnell", arguments: {"prompt": "A mountain lake at sunset"}) -``` +--- -### LLM inference +## 8. Hints -``` -sandbase_inspect(name: "sandbase_openai_gpt_4o") -sandbase_run(name: "sandbase_openai_gpt_4o", arguments: { - "messages": [{"role": "user", "content": "Explain quantum computing briefly"}] -}) -``` +Responses may include `hints` (and `warnings`): suggested next steps, caveats, or alternatives from the server. Read them before deciding your next move and prefer them over guessing. -### Check recent costs +--- -``` -sandbase_runs(limit: 5) -→ [{ "model": "openai/gpt-4o", "cost": "0.000325", "status": "completed" }, ...] -``` +## 9. Rules for agents ---- +1. **Check the user's stack first, then discover.** Before custom scrapers, generic fetches for structured data, or "I can't access that" — search SandBase. +2. **Never route around the user's own tools.** SandBase costs money; theirs may not. +3. **Prefer MCP when connected**; use the CLI when MCP is missing or broken, or to write large outputs to disk. +4. **Always inspect before running.** Never guess parameters; `inputSchema` is the source of truth. +5. **Keep discover queries short** and decompose multi-source tasks. +6. **Poll, don't re-run.** Slow tools return a run id; poll every 5–10 seconds. +7. **Start with conservative limits** (5–10 results). +8. **Report cost when relevant**, and check the balance before batches. +9. **Read error text and hints, and act on them.** They name the fix. +10. **Tool schemas and `sandbase --help` are authoritative** if they disagree with this document. -## Rules +Before sending sensitive or regulated data, review the [SandBase Privacy Policy](https://www.sandbase.ai/privacy) and [Terms of Service](https://www.sandbase.ai/terms), plus the selected upstream provider's policies. Send only the minimum data needed. -1. **Discover first** — always verify a tool exists before running it. -2. **Inspect before run** — read the inputSchema. Never guess parameters. -3. **Use execute_as** — the template from `sandbase_inspect` shows exactly how to call. -4. **Respect the user's stack** — don't replace their existing tools. -5. **Start small** — use small limits on first calls for scraping/search tools. -6. **Poll async runs** — use `sandbase_run_get` for long-running operations. -7. **Report costs** — mention pricing when the user cares about budget. -8. **One call per turn** — wait for results before the next call. +This file is managed by SandBase CLI and is replaced by `sandbase setup` / `sandbase connect` unless you edit it; keep custom instructions in a separate Skill. Latest copy: https://www.sandbase.ai/skill.md diff --git a/skills/sandbase/skill.json b/skills/sandbase/skill.json index 33ae42a..c77ce91 100644 --- a/skills/sandbase/skill.json +++ b/skills/sandbase/skill.json @@ -1,7 +1,7 @@ { "name": "sandbase", - "version": "0.1.17", - "description": "Access 2,000+ AI models and API tools through one MCP interface for inference, media generation, search, scraping, embeddings, social data, and structured retrieval.", + "version": "0.2.0", + "description": "Discover and run 2,000+ AI models and APIs (media generation, social data, search, scraping) through one MCP interface or CLI, paid per call from one balance.", "author": "SandBase AI", "license": "Apache-2.0", "homepage": "https://www.sandbase.ai", diff --git a/src/agent/client.ts b/src/agent/client.ts new file mode 100644 index 0000000..3cb0490 --- /dev/null +++ b/src/agent/client.ts @@ -0,0 +1,253 @@ +// Agent-facing SandBase client: one JSON-RPC-over-HTTP path to the public MCP +// endpoint, shared by the discover/inspect/run/runs/balance commands and by +// `sandbase doctor`. Every failure becomes a SandBaseError carrying a stable +// code plus a concrete next step, so an agent reading the output knows what to +// do instead of giving up. +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { sandbaseHome } from "../paths.js"; +import { clients, type Client, type CredentialRecord } from "../types.js"; + +export const DEFAULT_MCP_URL = "https://api.sandbase.ai/v1/mcp"; +export const FALLBACK_MCP_URL = "https://www.sandbase.ai/v1/mcp"; +const PREFERENCE_TTL_MS = 6 * 60 * 60 * 1000; + +export type ErrorCode = + | "AUTH_REQUIRED" | "AUTH_FAILED" | "INSUFFICIENT_BALANCE" | "FORBIDDEN" | "NOT_FOUND" | "RATE_LIMITED" + | "INVALID_INPUT" | "NETWORK" | "UPSTREAM" | "RUN_FAILED" | "INVALID_RESPONSE" | "TOOL_ERROR" | "USAGE"; + +export class SandBaseError extends Error { + constructor(readonly code: ErrorCode, message: string, readonly hint?: string, readonly status?: number, readonly runId?: string) { super(message); this.name = "SandBaseError"; } +} + +export function webUrl(env = process.env): string { return (env.SANDBASE_WEB_URL || "https://www.sandbase.ai").replace(/\/+$/, ""); } +export function billingUrl(env = process.env): string { return `${webUrl(env)}/console/billing`; } +export function keysUrl(env = process.env): string { return `${webUrl(env)}/console/keys`; } +export const CONNECT_COMMAND = "npx -y @sandbaseai/cli@latest connect"; + +export function validMcpUrl(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash && url.pathname === "/v1/mcp" ? url.href : undefined; + } catch { return undefined; } +} + +/** Ordered endpoint candidates: the recorded URL first, then known-good mirrors of the same API. */ +export function endpointCandidates(primary: string, env = process.env): string[] { + const out = [primary]; + const configured = env.SANDBASE_MCP_FALLBACK_URLS; + if (configured !== undefined) { + for (const item of configured.split(",").map(value => value.trim()).filter(Boolean)) { const url = validMcpUrl(item); if (url) out.push(url); } + } else { + try { const host = new URL(primary).hostname; if (host === "api.sandbase.ai" || host === "sandbase.ai") out.push(FALLBACK_MCP_URL); } catch { /* primary was validated by the caller */ } + } + return [...new Set(out)]; +} + +export interface Endpoint { credential: string; urls: string[]; source: string } +export interface StoredKey { key: string; mcpUrl?: string; addedAt: string } +export function keyFilePath(env = process.env): string { return join(sandbaseHome(env), "cli-key.json"); } +export async function readStoredKey(env = process.env): Promise { + try { const value = JSON.parse(await readFile(keyFilePath(env), "utf8")) as StoredKey; return typeof value?.key === "string" && value.key ? value : undefined; } + catch { return undefined; } +} +async function readCredentials(env: NodeJS.ProcessEnv): Promise>> { + try { const value: unknown = JSON.parse(await readFile(join(sandbaseHome(env), "credentials.json"), "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value as Partial> : {}; } + catch { return {}; } +} +function usable(record: CredentialRecord | undefined, client: Client): record is CredentialRecord { return !!record && record.client === client && typeof record.credential === "string" && !!record.credential && !!validMcpUrl(record.mcpUrl); } + +/** + * Credential precedence: SANDBASE_API_KEY → `sandbase keys add` → the record of + * --client / SANDBASE_CLIENT → any connected client record. Connected client + * records are the browser sign-in result, so most users never paste a key. + */ +export async function resolveEndpoint(options: { client?: string } = {}, env = process.env): Promise { + const override = env.SANDBASE_MCP_URL ? validMcpUrl(env.SANDBASE_MCP_URL) : undefined; + if (env.SANDBASE_MCP_URL && !override) throw new SandBaseError("USAGE", "SANDBASE_MCP_URL must be an http(s) URL ending in /v1/mcp."); + if (env.SANDBASE_API_KEY) return { credential: env.SANDBASE_API_KEY, urls: endpointCandidates(override || DEFAULT_MCP_URL, env), source: "env:SANDBASE_API_KEY" }; + const wanted = options.client || env.SANDBASE_CLIENT; + if (!wanted) { + const stored = await readStoredKey(env); + if (stored) return { credential: stored.key, urls: endpointCandidates(override || validMcpUrl(stored.mcpUrl) || DEFAULT_MCP_URL, env), source: "keys" }; + } + const records = await readCredentials(env); + if (wanted) { + const record = records[wanted as Client]; + if (!usable(record, wanted as Client)) throw new SandBaseError("AUTH_REQUIRED", `No SandBase credential is stored for client "${wanted}".`, `Run \`${CONNECT_COMMAND} --client ${wanted}\`, or omit --client to use any connected client.`); + return { credential: record.credential, urls: endpointCandidates(override || record.mcpUrl, env), source: `client:${wanted}` }; + } + for (const client of clients) { + const record = records[client]; + if (usable(record, client)) return { credential: record.credential, urls: endpointCandidates(override || record.mcpUrl, env), source: `client:${client}` }; + } + throw new SandBaseError("AUTH_REQUIRED", "No SandBase credential found on this machine.", `Run \`${CONNECT_COMMAND}\` (browser sign-in, nothing to paste), or create an API key at ${keysUrl(env)} and run \`sandbase keys add -k \` (or set SANDBASE_API_KEY).`); +} + +/** True when the request provably never reached the server, so resending it elsewhere cannot duplicate work. */ +export function isPreSendFailure(error: unknown): boolean { + const err = error as { code?: string; cause?: { code?: string } }; + const code = err?.cause?.code || err?.code || ""; + return ["UND_ERR_CONNECT_TIMEOUT", "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "EADDRNOTAVAIL"].includes(code) || /CERT|SSL|TLS/i.test(code); +} + +/** Calls that start or cancel billable work must never be replayed on another host after they may have been sent. */ +export function isIdempotentRequest(method: string, params: unknown): boolean { + if (method !== "tools/call") return true; + const name = (params as { name?: unknown } | undefined)?.name; + return name !== "sandbase_run" && name !== "sandbase_run_cancel"; +} + +export function describeNetworkFailure(error: unknown): string { + const err = error as { name?: string; code?: string; cause?: { code?: string; name?: string; message?: string } }; + const code = err?.cause?.code || err?.code || ""; + if (err?.name === "TimeoutError" || err?.name === "AbortError" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ETIMEDOUT") return "connection timed out"; + if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "DNS lookup failed"; + if (code === "ECONNREFUSED") return "connection refused"; + if (code === "ECONNRESET" || code === "UND_ERR_SOCKET") return "connection reset"; + if (/CERT|SSL|TLS/i.test(code)) return `TLS error (${code})`; + return code ? `network error (${code})` : "network error"; +} + +/** Map a server/tool error message to the most useful next step. */ +export function hintForMessage(message: string, env = process.env): string | undefined { + const text = message.toLowerCase(); + // Auth first: "API key not found" is a sign-in problem, not a misspelled tool. + if (/unauthori[sz]ed|api key|credential|revoked|expired|authenticat|sign-in|signin/.test(text)) return `Sign in again with \`${CONNECT_COMMAND}\`, or check SANDBASE_API_KEY.`; + if (/insufficient (balance|credit|funds)|payment required|spending limit|top up/.test(text)) return `Top up at ${billingUrl(env)}, then retry.`; + if (/rate.?limit|too many requests|429/.test(text)) return "Rate limited. Wait 10-30 seconds, then retry once."; + if (/run .*not found|task not found/.test(text)) return "Use the run_id printed by `sandbase run` (or find it with `sandbase runs list`). Agent Service runs also need --service ."; + if (/invalid service request|unknown tool|tool(?: '[^']*')? (?:was )?not found|model not found|tool name is required/.test(text)) return "Check the exact name with `sandbase discover -q \"\"` (MCP: sandbase_discover). Names look like sandbase_twitter_web_search_timeline."; + if (/invalid (params|arguments|input)|is required|schema|must be|unexpected field|additional propert/.test(text)) return "Re-read the input schema with `sandbase inspect ` (MCP: sandbase_inspect) and pass exactly those fields."; + if (/capability call failed|upstream|provider|bad gateway|timeout|unavailable/.test(text)) return "The upstream provider failed. Retry once; if it fails again, choose another tool from discover."; + return undefined; +} + +export function httpError(status: number, body: string, host: string, env = process.env): SandBaseError { + let message = ""; + try { const parsed = JSON.parse(body) as { error?: unknown; message?: unknown }; const e = parsed.error; message = typeof e === "string" ? e : e && typeof e === "object" && typeof (e as { message?: unknown }).message === "string" ? (e as { message: string }).message : typeof parsed.message === "string" ? parsed.message : ""; } catch { /* gateway HTML or empty body: the status carries the meaning */ } + const detail = message ? `: ${message}` : ""; + if (status === 401) return new SandBaseError("AUTH_FAILED", `SandBase rejected the saved sign-in or API key (401)${detail}.`, `Sign in again with \`${CONNECT_COMMAND}\`, or create a new key at ${keysUrl(env)}.`, status); + if (status === 402) return new SandBaseError("INSUFFICIENT_BALANCE", `Insufficient balance or spending limit reached (402)${detail}.`, `Top up at ${billingUrl(env)}, then retry.`, status); + if (status === 403) return new SandBaseError("FORBIDDEN", `This credential is not allowed to call ${host} (403)${detail}.`, `Use a credential issued by \`${CONNECT_COMMAND}\` or a full API key from ${keysUrl(env)}.`, status); + if (status === 404) return new SandBaseError("NOT_FOUND", `MCP endpoint not found on ${host} (404)${detail}.`, "Check SANDBASE_MCP_URL; it must end in /v1/mcp.", status); + if (status === 429) return new SandBaseError("RATE_LIMITED", `Rate limited (429)${detail}.`, "Wait 10-30 seconds, then retry once.", status); + if (status >= 500) return new SandBaseError("UPSTREAM", `SandBase returned HTTP ${status} from ${host}${detail}.`, "Temporary server error. Retry in a minute; run `sandbase doctor` if it persists.", status); + return new SandBaseError("UPSTREAM", `SandBase returned HTTP ${status} from ${host}${detail}.`, hintForMessage(message, env), status); +} + +interface RpcResponse { id?: unknown; result?: unknown; error?: { code?: number; message?: string; data?: unknown } } +function parseRpcBody(text: string, contentType: string, id: number): RpcResponse | undefined { + const candidates: string[] = []; + if (contentType.includes("text/event-stream")) { for (const line of text.split(/\r?\n/)) if (line.startsWith("data:")) candidates.push(line.slice(5).trim()); } + else candidates.push(text, ...text.split(/\r?\n/)); + let fallback: RpcResponse | undefined; + for (const candidate of candidates) { + if (!candidate || candidate === "[DONE]") continue; + try { const value = JSON.parse(candidate) as RpcResponse; if (!value || typeof value !== "object") continue; if (value.id === id) return value; fallback ??= value; } catch { /* keep scanning */ } + } + return fallback; +} + +export interface ToolOutput { data: Record; text: string } +export type FetchLike = (url: string, init: { method: string; headers: Record; body: string; signal: AbortSignal }) => Promise<{ ok: boolean; status: number; headers: { get(name: string): string | null }; text(): Promise }>; + +export class SandBaseMCPClient { + private session: string | undefined; + private preferred = 0; + private nextId = 1; + readonly attempts: Array<{ url: string; outcome: string }> = []; + constructor(readonly endpoint: Endpoint, private readonly fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike, private readonly timeoutMs = 120_000, private readonly env = process.env) { + // Each CLI invocation is a new process: remember which mirror answered so an + // unreachable primary costs one connect timeout per 6 hours, not per command. + try { + const cached = JSON.parse(readFileSync(this.preferenceFile(), "utf8")) as { primary?: string; url?: string; savedAt?: number }; + const index = cached.url ? endpoint.urls.indexOf(cached.url) : -1; + if (cached.primary === endpoint.urls[0] && index > 0 && Date.now() - (cached.savedAt || 0) < PREFERENCE_TTL_MS) this.preferred = index; + } catch { /* no preference yet */ } + } + private preferenceFile(): string { return join(sandbaseHome(this.env), "cache", "endpoint.json"); } + private rememberPreference(index: number): void { + const primary = this.endpoint.urls[0]; + try { + if (index === 0) { rmSync(this.preferenceFile(), { force: true }); return; } + mkdirSync(dirname(this.preferenceFile()), { recursive: true, mode: 0o700 }); + writeFileSync(this.preferenceFile(), JSON.stringify({ primary, url: this.endpoint.urls[index], savedAt: Date.now() }) + "\n", { mode: 0o600 }); + } catch { /* preference is an optimization only */ } + } + /** The endpoint that last answered (or the first candidate before any call). */ + activeUrl(): string { return this.endpoint.urls[this.preferred] || this.endpoint.urls[0] || DEFAULT_MCP_URL; } + + async rpc(method: string, params?: unknown): Promise { + const id = this.nextId++; + const body = JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }); + const idempotent = isIdempotentRequest(method, params); + const failures: string[] = []; + let lastServerError: SandBaseError | undefined; + const order = [...this.endpoint.urls.slice(this.preferred), ...this.endpoint.urls.slice(0, this.preferred)]; + for (const [position, url] of order.entries()) { + const host = new URL(url).host; + const isLast = position === order.length - 1; + let response; + try { + const headers: Record = { authorization: `Bearer ${this.endpoint.credential}`, "content-type": "application/json", accept: "application/json, text/event-stream", "x-sandbase-client": "cli" }; + if (this.session) headers["mcp-session-id"] = this.session; + response = await this.fetchImpl(url, { method: "POST", headers, body, signal: AbortSignal.timeout(this.timeoutMs) }); + } catch (error) { + const reason = describeNetworkFailure(error); this.attempts.push({ url, outcome: reason }); + if (!idempotent && !isPreSendFailure(error)) { + // The request may already be running server-side; replaying it would bill twice. + throw new SandBaseError("NETWORK", `The request to ${host} was interrupted after it was sent (${reason}); it may have started a run.`, "Do not re-run yet. Check `sandbase runs list` for the run and poll it with `sandbase runs get -r -w`."); + } + failures.push(`${host}: ${reason}`); + continue; + } + this.attempts.push({ url, outcome: `HTTP ${response.status}` }); + const session = response.headers.get("mcp-session-id"); if (session) this.session = session; + const text = await response.text(); + if (response.status >= 500 && idempotent && !isLast) { + // Gateway/origin errors are what a mirror can fix; only safe for idempotent calls. + lastServerError = httpError(response.status, text, host, this.env); failures.push(`${host}: HTTP ${response.status}`); + continue; + } + const index = this.endpoint.urls.indexOf(url); + if (index !== this.preferred || failures.length) this.rememberPreference(index); + this.preferred = index; + if (!response.ok) throw httpError(response.status, text, host, this.env); + const parsed = parseRpcBody(text, response.headers.get("content-type") || "", id); + if (!parsed) throw new SandBaseError("INVALID_RESPONSE", `Expected JSON-RPC from ${host} but got ${text ? `"${text.slice(0, 80).replace(/\s+/g, " ")}"` : "an empty body"}.`, "A proxy or captive portal may be intercepting traffic. Run `sandbase doctor`."); + if (parsed.error) { + const message = parsed.error.message || "request failed"; + const code: ErrorCode = parsed.error.code === -32602 ? "INVALID_INPUT" : /unknown tool|tool(?: '[^']*')? (?:was )?not found|run not found|invalid service request/i.test(message) ? "NOT_FOUND" : "TOOL_ERROR"; + throw new SandBaseError(code, message, hintForMessage(message, this.env)); + } + return parsed.result; + } + if (lastServerError && failures.every(failure => /HTTP 5\d\d$/.test(failure))) throw lastServerError; + throw new SandBaseError("NETWORK", `Cannot reach SandBase (${failures.join("; ")}).`, "Check your network, proxy, or VPN, then run `sandbase doctor`. Set SANDBASE_MCP_URL to use a different endpoint."); + } + + async tool(name: string, args: Record = {}): Promise { + return toolOutput(await this.rpc("tools/call", { name, arguments: args }), this.env); + } +} + +const serverErrorCodes: Record = { tool_not_found: "NOT_FOUND", run_not_found: "NOT_FOUND", invalid_arguments: "INVALID_INPUT", insufficient_balance: "INSUFFICIENT_BALANCE", rate_limited: "RATE_LIMITED", unauthorized: "AUTH_FAILED", run_failed: "RUN_FAILED", upstream_error: "UPSTREAM", internal_error: "UPSTREAM" }; + +export function toolOutput(result: unknown, env = process.env): ToolOutput { + const value = (result || {}) as { structuredContent?: unknown; content?: Array<{ type?: string; text?: string }>; isError?: boolean }; + const text = (value.content || []).filter(item => item?.type === "text" && typeof item.text === "string").map(item => item.text).join("\n"); + if (value.isError) { + // Servers with B-283 send {error:{code,message,hint}, run_id?}; older ones only text. + const structured = (value.structuredContent && typeof value.structuredContent === "object" ? value.structuredContent : {}) as { error?: { code?: unknown; message?: unknown; hint?: unknown }; run_id?: unknown }; + const serverCode = typeof structured.error?.code === "string" ? structured.error.code : ""; + const message = typeof structured.error?.message === "string" && structured.error.message ? structured.error.message : text || "The tool reported an error."; + const hint = typeof structured.error?.hint === "string" && structured.error.hint ? structured.error.hint : hintForMessage(text, env); + throw new SandBaseError(serverErrorCodes[serverCode] || "TOOL_ERROR", message, hint, undefined, typeof structured.run_id === "string" ? structured.run_id : undefined); + } + if (value.structuredContent && typeof value.structuredContent === "object" && !Array.isArray(value.structuredContent)) return { data: value.structuredContent as Record, text }; + try { const parsed: unknown = JSON.parse(text); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return { data: parsed as Record, text }; } catch { /* plain text result */ } + return { data: { text }, text }; +} diff --git a/src/agent/commands.ts b/src/agent/commands.ts new file mode 100644 index 0000000..dcefe2a --- /dev/null +++ b/src/agent/commands.ts @@ -0,0 +1,407 @@ +// Shell transport for agents: discover → inspect → run → runs get, plus +// balance and key management. Mirrors the sandbase_* MCP tools one-to-one so an +// agent without MCP (or with a broken MCP session) still has a working path, +// and so large results can go straight to disk with -o instead of the context. +import { constants as fsConstants } from "node:fs"; +import { access, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve as resolvePath } from "node:path"; +import { parseArgs } from "node:util"; +import { atomicWrite } from "../fs-safe.js"; +import { CONNECT_COMMAND, keyFilePath, keysUrl, readStoredKey, resolveEndpoint, SandBaseError, SandBaseMCPClient, toolOutput, validMcpUrl, type FetchLike } from "./client.js"; +import { updateNotice } from "./update.js"; + +export const agentCommands = ["discover", "inspect", "run", "runs", "balance", "keys"] as const; +export type AgentCommand = typeof agentCommands[number]; +export function isAgentCommand(value: string | undefined): value is AgentCommand { return agentCommands.includes(value as AgentCommand); } + +export interface AgentDeps { env?: NodeJS.ProcessEnv; fetchImpl?: FetchLike; out?: (text: string) => void; err?: (text: string) => void; sleep?: (ms: number) => Promise; now?: () => number; stdin?: () => Promise } + +export const agentUsage = `SandBase agent commands (same capabilities as the sandbase_* MCP tools): + sandbase discover -q "" [-t llm|api|multimodal|embedding|service] [--vendor ] [-l 10] [-j] + sandbase inspect [-j] + sandbase run [-i '' | -f input.json] [-w] [--timeout 300] [-o result.json] [-j] + sandbase runs get -r [--service ] [-w] [--timeout 300] [-o result.json] [-j] + sandbase runs list [-l 10] [--model ] [--service ] [-j] + sandbase balance [-j] + sandbase keys add -k - (reads the key from stdin) | keys list | keys remove +Global: --client picks a connected client's credential; SANDBASE_API_KEY overrides all; -j prints JSON.`; + +// Only an explicit failure ends a run as failed; any status we do not know +// (IN_QUEUE, created, unknown, …) is treated as still running so agents poll +// instead of giving up or paying for a re-run. +const SUCCEEDED = new Set(["completed", "succeeded", "success", "done"]); +const FAILED = new Set(["failed", "failure", "error", "errored", "cancelled", "canceled", "timeout", "timed_out", "expired", "rejected", "blocked", "stopped"]); +type Phase = "running" | "succeeded" | "failed"; +function phaseOf(status: unknown): Phase { const value = String(status ?? "").toLowerCase(); return SUCCEEDED.has(value) ? "succeeded" : FAILED.has(value) ? "failed" : "running"; } +function hasOutput(view: { output?: unknown }): boolean { return view.output !== undefined && view.output !== null; } + +interface RunView { run_id?: string; service_id?: string; idempotency_key?: string; tool?: string; status: string; output?: unknown; error?: unknown; cost?: unknown; cost_status?: unknown; hints?: unknown; timed_out?: boolean } + +class Output { + readonly notes: string[] = []; + constructor(readonly json: boolean, private readonly out: (text: string) => void, private readonly err: (text: string) => void) {} + print(text: string): void { this.out(text); } + data(value: Record): void { this.out(JSON.stringify(this.notes.length ? { ...value, notes: this.notes } : value, null, 2)); } + error(error: SandBaseError): void { + if (this.json) this.out(JSON.stringify({ error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) }, ...(error.runId ? { run_id: error.runId } : {}), ...(this.notes.length ? { notes: this.notes } : {}) }, null, 2)); + else this.err(`Error [${error.code}]: ${error.message}${error.runId ? `\nRun: ${error.runId}` : ""}${error.hint ? `\nNext: ${error.hint}` : ""}`); + } + notice(text: string): void { if (this.json) this.notes.push(text); else this.err(text); } +} + +function truncate(value: string, width: number): string { const flat = value.replace(/\s+/g, " ").trim(); return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat; } +function table(headers: string[], rows: string[][]): string { + const widths = headers.map((header, i) => Math.max(header.length, ...rows.map(row => (row[i] || "").length))); + const line = (cells: string[]) => cells.map((cell, i) => i === cells.length - 1 ? cell : cell.padEnd(widths[i]!)).join(" ").trimEnd(); + return [line(headers), ...rows.map(line)].join("\n"); +} +function trimNumber(value: unknown): string { const text = String(value); return /^-?\d+(\.\d+)?$/.test(text) ? String(Number(text)) : text; } +export function formatPrice(pricing: unknown): string { + if (!pricing || typeof pricing !== "object") return "see inspect"; + const p = pricing as Record; + if (p.input_per_million !== undefined || p.output_per_million !== undefined) return `$${trimNumber(p.input_per_million ?? "?")}/$${trimNumber(p.output_per_million ?? "?")} per 1M tok`; + if (p.base_price !== undefined && Number(p.base_price) > 0) return `$${trimNumber(p.base_price)}/call`; + if (typeof p.formula === "string" && p.formula) return "formula (see inspect)"; + return "see inspect"; +} +function hintLines(hints: unknown): string[] { + if (!hints) return []; + if (Array.isArray(hints)) return hints.filter(item => item !== undefined && item !== null).map(item => typeof item === "string" ? item : JSON.stringify(item)); + if (typeof hints === "object") return Object.entries(hints as Record).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`); + return [String(hints)]; +} +function printHints(out: Output, ...sources: unknown[]): void { const lines = sources.flatMap(hintLines); if (lines.length) out.print(["", "Hints:", ...lines.map(line => ` - ${line}`)].join("\n")); } +function collectUrls(value: unknown, found = new Set()): Set { + if (found.size >= 20) return found; + if (typeof value === "string") { if (/^https?:\/\/\S+$/.test(value)) found.add(value); } + else if (Array.isArray(value)) for (const item of value) collectUrls(item, found); + else if (value && typeof value === "object") for (const item of Object.values(value)) collectUrls(item, found); + return found; +} +function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } + +type OptionSpec = Record; +const commonOptions = { json: { type: "boolean", short: "j" }, client: { type: "string" }, help: { type: "boolean", short: "h" } } as const; +function parse(argv: string[], options: T) { + try { return parseArgs({ args: argv, options: { ...commonOptions, ...options }, allowPositionals: true, strict: true }); } + catch (error) { throw new SandBaseError("USAGE", error instanceof Error ? error.message : "Invalid arguments.", `Usage:\n${agentUsage}`); } +} +function parseJsonObject(text: string, label: string): Record { + let value: unknown; + try { value = JSON.parse(text); } catch { throw new SandBaseError("USAGE", `${label} is not valid JSON.`, `Use ${label === "--input" ? "-i" : "-f"} '{"key": "value"}' with the fields from \`sandbase inspect \`.`); } + if (!value || typeof value !== "object" || Array.isArray(value)) throw new SandBaseError("USAGE", `${label} must be a JSON object.`, "Pass an object such as '{\"prompt\": \"...\"}'."); + return value as Record; +} +function positiveSeconds(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const seconds = Number(value); if (!Number.isFinite(seconds) || seconds <= 0) throw new SandBaseError("USAGE", "--timeout must be a positive number of seconds."); + return Math.min(seconds, 3600); +} + +async function writeResult(path: string, output: unknown): Promise { + await writeFile(path, typeof output === "string" ? output : `${JSON.stringify(output ?? null, null, 2)}\n`, "utf8"); +} + +function runViewFromRun(data: Record, tool: string): RunView { + const runId = (data.run_id ?? data.prediction_id ?? data.taskId ?? data.id) as string | undefined; + // No status and no run id means a synchronous tool answered directly (possibly plain text). + const syncOutput = data.status === undefined && !runId; + const output = data.output !== undefined ? data.output : syncOutput ? (Object.keys(data).length === 1 && "text" in data ? data.text : data) : undefined; + return { ...(runId ? { run_id: String(runId) } : {}), ...(data.service_id ? { service_id: String(data.service_id) } : {}), tool, status: String(data.status ?? (syncOutput ? "completed" : "running")), ...(output !== undefined ? { output } : {}), ...(data.error !== undefined ? { error: data.error } : {}), ...(data.cost !== undefined ? { cost: data.cost } : {}), ...(data.cost_status !== undefined ? { cost_status: data.cost_status } : {}), ...(data.hints !== undefined ? { hints: data.hints } : {}) }; +} + +/** Fail before spending money when -o cannot be written. */ +async function assertWritableOutput(path: string | undefined): Promise { + if (!path) return; + try { await access(dirname(resolvePath(path)), fsConstants.W_OK); } + catch { throw new SandBaseError("USAGE", `Cannot write the output file ${path}: its directory does not exist or is not writable.`, "Pick a writable path for -o; nothing was run."); } +} + +/** After a run was accepted, every failure must still tell the agent how to resume it instead of re-running (and paying) again. */ +function withRunContext(error: unknown, view: RunView): SandBaseError { + const resume = `sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w`; + if (error instanceof SandBaseError) return new SandBaseError(error.code, error.message, `${error.hint ? `${error.hint} ` : ""}The run ${view.run_id} was already started; resume with \`${resume}\` instead of running it again.`, error.status, view.run_id); + return new SandBaseError("TOOL_ERROR", error instanceof Error ? error.message : "Unknown failure", `The run ${view.run_id} was already started; resume with \`${resume}\`.`, undefined, view.run_id); +} + +/** One status read: sandbase_run_get for status/cost, then MCP tasks/result for the output of finished model runs. */ +async function readRun(client: SandBaseMCPClient, runId: string, serviceId?: string): Promise { + const { data } = await client.tool("sandbase_run_get", { run_id: runId, ...(serviceId ? { service_id: serviceId } : {}) }); + const view: RunView = { run_id: runId, ...(serviceId ? { service_id: serviceId } : {}), status: String(data.status ?? "unknown"), ...(data.model ? { tool: String(data.model) } : {}), ...(data.cost !== undefined ? { cost: data.cost } : {}), ...(data.hints !== undefined ? { hints: data.hints } : {}) }; + if (data.output !== undefined && data.output !== null) view.output = data.output; else if (serviceId && (data.items !== undefined || data.result !== undefined)) view.output = data.items ?? data.result; + if (data.error !== undefined && data.error !== null) view.error = data.error; + if (!serviceId && phaseOf(view.status) !== "running" && !hasOutput(view)) { + try { + const result = await client.rpc("tasks/result", { taskId: runId }) as { task?: unknown }; + if (!result?.task) { const task = toolOutput(result).data; if (task.output !== undefined && task.output !== null) view.output = task.output; if (task.error !== undefined && task.error !== null && view.error === undefined) view.error = task.error; } + } catch { /* older servers: status and cost are still accurate */ } + } + return view; +} + +async function pollRun(client: SandBaseMCPClient, initial: RunView, timeoutSeconds: number, deps: Required>): Promise { + if (!initial.run_id) return initial; + const deadline = deps.now() + timeoutSeconds * 1000; + let view = initial, delay = 1000, emptyCompletions = 0; + for (;;) { + const phase = phaseOf(view.status); + // A completed media run can briefly report no output while its files are transferred. + if (phase === "failed" || (phase === "succeeded" && (hasOutput(view) || emptyCompletions >= 8))) return view; + if (phase === "succeeded") emptyCompletions++; + if (deps.now() + delay > deadline) return { ...view, timed_out: true }; + await deps.sleep(delay); delay = Math.min(Math.round(delay * 1.5), 10_000); + const next = await readRun(client, initial.run_id, initial.service_id); + const tool = view.tool ?? next.tool; + view = { ...view, ...next, ...(tool ? { tool } : {}) }; + } +} + +function printRun(out: Output, view: RunView, outputFile?: string): void { + const phase = phaseOf(view.status); + const lines = [`Run: ${view.run_id ?? "(synchronous)"}${view.tool ? ` tool=${view.tool}` : ""}`, `Status: ${view.status}${view.timed_out ? " (still running when --timeout elapsed)" : ""}`]; + if (view.cost !== undefined && view.cost !== null && view.cost !== "") lines.push(`Cost: $${trimNumber(view.cost)}`); + if (view.error !== undefined && view.error !== null) lines.push(`Error: ${typeof view.error === "string" ? view.error : JSON.stringify(view.error)}`); + if (view.idempotency_key) lines.push(`Idempotency key: ${view.idempotency_key} (reuse it with the same input when retrying this service run)`); + if (view.cost === undefined && view.cost_status === "settling") lines.push("Cost: settling (see `sandbase runs list` shortly)"); + if (phase === "succeeded" && hasOutput(view)) { + const urls = [...collectUrls(view.output)]; + if (urls.length) lines.push("Files:", ...urls.map(url => ` ${url}`)); + if (outputFile) lines.push(`Saved output to ${outputFile}`); + else { const text = typeof view.output === "string" ? view.output : JSON.stringify(view.output, null, 2); lines.push("Output:", text.length > 6000 ? `${text.slice(0, 6000)}\n…(truncated ${text.length - 6000} chars; rerun \`sandbase runs get -r ${view.run_id ?? ""} -o result.json\` to save everything)` : text); } + } + if (phase === "running" && view.run_id) lines.push(`Next: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w${outputFile ? ` -o ${outputFile}` : ""} (poll every 5-10s; runs take 1-300s)`); + if (phase === "succeeded" && !hasOutput(view) && view.run_id) lines.push(`Output is not available yet. Retry: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""}${outputFile ? ` -o ${outputFile}` : ""}`); + out.print(lines.join("\n")); + printHints(out, view.hints); +} + +async function finishRun(out: Output, view: RunView, outputFile: string | undefined): Promise { + const phase = phaseOf(view.status); + const saved = !!outputFile && phase === "succeeded" && hasOutput(view); + if (saved) await writeResult(outputFile!, view.output); + if (out.json) { + // With -o the output goes to disk only, so a large result never enters the agent's context. + const value: Record = { ...view }; + if (saved) { delete value.output; value.output_file = outputFile; } + out.data(value); + } else printRun(out, view, saved ? outputFile : undefined); + return phase === "failed" ? 1 : 0; +} + +async function client(values: { client?: string | undefined }, env: NodeJS.ProcessEnv, fetchImpl?: FetchLike): Promise { + const endpoint = await resolveEndpoint(values.client ? { client: values.client } : {}, env); + return new SandBaseMCPClient(endpoint, fetchImpl, 120_000, env); +} + +async function discover(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetchImpl?: FetchLike): Promise { + const { values, positionals } = parse(argv, { query: { type: "string", short: "q" }, type: { type: "string", short: "t" }, vendor: { type: "string" }, limit: { type: "string", short: "l" } }); + const q = (values.query ?? positionals.join(" ")).trim(); + const limit = values.limit === undefined ? 10 : Number(values.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SandBaseError("USAGE", "--limit must be an integer between 1 and 100."); + if (!q && !values.type && !values.vendor) throw new SandBaseError("USAGE", "Describe what you need with -q.", 'Example: sandbase discover -q "twitter posts" (short noun phrases work best; Chinese works too)'); + const api = await client(values, env, fetchImpl); + const { data } = await api.tool("sandbase_discover", { ...(q ? { q } : {}), ...(values.type ? { type: values.type } : {}), ...(values.vendor ? { vendor: values.vendor } : {}), limit }); + const items = (Array.isArray(data.data) ? data.data : []) as Array>; + if (out.json) { out.data(data); return 0; } + if (!items.length) { out.print(`No tools matched "${q}". Try a shorter noun phrase, a synonym, or filter with -t.`); printHints(out, data.hints, data.warnings); return 0; } + const rows = items.map(item => [String(item.tool_name ?? item.name ?? item.id ?? ""), String(item.category ?? item.type ?? ""), String(item.vendor ?? item.vendor_slug ?? ""), formatPrice(item.pricing), item.run_count === undefined ? "" : String(item.run_count), truncate(String(item.description ?? ""), 70)]); + out.print([`Found ${items.length} tool${items.length === 1 ? "" : "s"}${q ? ` for "${q}"` : ""}:`, "", table(["TOOL", "TYPE", "VENDOR", "PRICE", "RUNS", "DESCRIPTION"], rows), "", "Next: sandbase inspect (input schema, exact price, and a ready-to-run command)"].join("\n")); + printHints(out, data.hints, data.warnings); + return 0; +} + +/** Resolve a root-level local $ref (#/components/schemas/X, #/$defs/X, #/definitions/X) so agents see real fields. */ +export function resolveSchema(schema: unknown): Record { + let current = (schema && typeof schema === "object" ? schema : {}) as Record; + const root = current; + for (let depth = 0; depth < 5 && typeof current.$ref === "string"; depth++) { + const path = (current.$ref as string).replace(/^#\//, "").split("/").map(part => part.replace(/~1/g, "/").replace(/~0/g, "~")); + if (!(current.$ref as string).startsWith("#/")) break; + let target: unknown = root; + for (const part of path) target = target && typeof target === "object" ? (target as Record)[part] : undefined; + if (!target || typeof target !== "object") break; + current = target as Record; + } + return current; +} + +function schemaRows(schema: unknown): { rows: string[][]; example: Record } { + const value = resolveSchema(schema) as { properties?: Record>; required?: string[] }; + const required = new Set(Array.isArray(value.required) ? value.required : []); + const rows: string[][] = [], example: Record = {}; + for (const [name, prop] of Object.entries(value.properties || {})) { + const type = Array.isArray(prop.type) ? prop.type.join("|") : String(prop.type ?? (prop.enum ? "enum" : "any")); + const extras = [prop.enum ? `one of: ${(prop.enum as unknown[]).join(", ")}` : "", prop.default !== undefined ? `default: ${JSON.stringify(prop.default)}` : ""].filter(Boolean).join("; "); + rows.push([name, type, required.has(name) ? "yes" : "", truncate([String(prop.description ?? ""), extras].filter(Boolean).join(" — "), 90)]); + if (required.has(name)) example[name] = prop.example ?? prop.default ?? (Array.isArray(prop.enum) ? prop.enum[0] : type === "integer" || type === "number" ? 1 : type === "boolean" ? true : type === "array" ? [] : `<${name}>`); + } + return { rows, example }; +} + +async function inspect(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetchImpl?: FetchLike): Promise { + const { values, positionals } = parse(argv, { name: { type: "string", short: "n" } }); + const name = (values.name ?? positionals[0] ?? "").trim(); + if (!name) throw new SandBaseError("USAGE", "Pass the tool name from discover.", "Example: sandbase inspect sandbase_twitter_web_search_timeline"); + const api = await client(values, env, fetchImpl); + const { data } = await api.tool("sandbase_inspect", { name }); + const tool = String(data.tool_name ?? data.name ?? name); + const rawSchema = data.inputSchema ?? data.input_schema; + const { rows, example } = schemaRows(rawSchema); + if (out.json) { + const resolved = resolveSchema(rawSchema); + out.data({ ...data, ...(resolved !== rawSchema ? { inputSchema: resolved } : {}), example_arguments: example, cli: `sandbase run ${tool} -i ${shellQuote(JSON.stringify(example))}` }); + return 0; + } + const pricing = (data.pricing || {}) as Record; + const priceLine = [formatPrice(pricing), typeof pricing.formula === "string" && pricing.formula ? `formula: ${pricing.formula}` : ""].filter(Boolean).join(" "); + const meta = [data.model, data.category ?? data.type, data.vendor].filter(Boolean).join(" · "); + const lines = [`${tool}${meta ? ` (${meta})` : ""}`, String(data.description ?? "").trim(), "", `Price: ${priceLine}`]; + lines.push("", rows.length ? `Input (send as JSON with -i):\n${table(["FIELD", "TYPE", "REQUIRED", "DESCRIPTION"], rows)}` : "Input: no parameters."); + lines.push("", "Run:", ` sandbase run ${tool} -i ${shellQuote(JSON.stringify(example))}${/video|image|audio|music|speech/i.test(`${data.category ?? ""} ${data.description ?? ""} ${tool}`) ? " -w -o result.json" : ""}`); + lines.push(`MCP: sandbase_run ${JSON.stringify({ name: tool, arguments: example })}`); + out.print(lines.join("\n")); + printHints(out, data.hints, data.notes); + return 0; +} + +async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Required>, fetchImpl?: FetchLike): Promise { + const { values, positionals } = parse(argv, { input: { type: "string", short: "i" }, file: { type: "string", short: "f" }, wait: { type: "boolean", short: "w" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); + const name = (positionals[0] ?? "").trim(); + if (!name) throw new SandBaseError("USAGE", "Pass the tool name to run.", "Example: sandbase run sandbase_twitter_web_search_timeline -i '{\"keyword\":\"AI agents\"}'"); + if (values.input && values.file) throw new SandBaseError("USAGE", "Use either -i or -f, not both."); + const timeout = positiveSeconds(values.timeout, 300); + let args: Record = {}; + if (values.input) args = parseJsonObject(values.input, "--input"); + if (values.file) { let text: string; try { text = await readFile(values.file, "utf8"); } catch { throw new SandBaseError("USAGE", `Cannot read input file ${values.file}.`); } args = parseJsonObject(text, "--file"); } + const generatedKey = name.startsWith("svc_") && typeof args.idempotency_key !== "string" ? `cli-${deps.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` : undefined; + if (generatedKey) args = { ...args, idempotency_key: generatedKey }; + await assertWritableOutput(values.output); + const api = await client(values, env, fetchImpl); + let view: RunView; + try { + const { data } = await api.tool("sandbase_run", { name, arguments: args }); + view = runViewFromRun(data, name); + } catch (error) { + // A run that started and then failed is a result (exit 1), not a transport error. + if (error instanceof SandBaseError && error.code === "RUN_FAILED") return finishRun(out, { ...(error.runId ? { run_id: error.runId } : {}), tool: name, status: "failed", error: error.message, ...(error.hint ? { hints: [error.hint] } : {}) }, values.output); + throw error; + } + if (name.startsWith("svc_") && !view.service_id) view.service_id = name; + if (typeof args.idempotency_key === "string" && view.service_id) view.idempotency_key = args.idempotency_key; + if (!view.run_id) return finishRun(out, view, values.output); + try { + if (values.wait && phaseOf(view.status) === "running") view = await pollRun(api, view, timeout, deps); + else if (phaseOf(view.status) !== "running" && view.cost === undefined && !view.service_id) { + try { const { data: detail } = await api.tool("sandbase_run_get", { run_id: view.run_id }); if (detail.cost !== undefined) view.cost = detail.cost; } catch { /* cost is informational */ } + } + return await finishRun(out, view, values.output); + } catch (error) { throw withRunContext(error, view); } +} + +async function runs(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Required>, fetchImpl?: FetchLike): Promise { + const [sub, ...rest] = argv; + if (sub === "get") { + const { values, positionals } = parse(rest, { run: { type: "string", short: "r" }, service: { type: "string" }, wait: { type: "boolean", short: "w" }, timeout: { type: "string" }, output: { type: "string", short: "o" } }); + const runId = (values.run ?? positionals[0] ?? "").trim(); + if (!runId) throw new SandBaseError("USAGE", "Pass the run id with -r.", "Example: sandbase runs get -r pred_abc123 -w -o result.json"); + const timeout = positiveSeconds(values.timeout, 300); + await assertWritableOutput(values.output); + const api = await client(values, env, fetchImpl); + let view = await readRun(api, runId, values.service); + try { + if (values.wait) view = await pollRun(api, view, timeout, deps); + return await finishRun(out, view, values.output); + } catch (error) { throw withRunContext(error, view); } + } + if (sub === "list") { + const { values } = parse(rest, { limit: { type: "string", short: "l" }, model: { type: "string" }, service: { type: "string" } }); + const limit = values.limit === undefined ? 10 : Number(values.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new SandBaseError("USAGE", "--limit must be an integer between 1 and 50."); + const api = await client(values, env, fetchImpl); + const { data } = await api.tool("sandbase_runs", { limit, ...(values.model ? { model: values.model } : {}), ...(values.service ? { service_id: values.service } : {}) }); + if (out.json) { out.data(data); return 0; } + const items = (Array.isArray(data.runs) ? data.runs : Array.isArray(data.data) ? data.data : Array.isArray(data.items) ? data.items : []) as Array>; + if (!items.length) { out.print("No runs yet."); return 0; } + out.print(table(["RUN", "MODEL", "STATUS", "COST", "CREATED"], items.map(item => [String(item.id ?? item.run_id ?? ""), String(item.model ?? item.service_id ?? ""), String(item.status ?? ""), item.cost === undefined ? "" : `$${trimNumber(item.cost)}`, String(item.created_at ?? "")]))); + return 0; + } + throw new SandBaseError("USAGE", "Use `sandbase runs get -r ` or `sandbase runs list`."); +} + +async function balance(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetchImpl?: FetchLike): Promise { + const { values } = parse(argv, {}); + const api = await client(values, env, fetchImpl); + const { data } = await api.tool("sandbase_account", {}); + if (out.json) { out.data(data); return 0; } + out.print(`Balance: $${trimNumber(data.balance ?? "?")} ${String(data.currency ?? "USD")}${data.credit_limit ? ` (credit limit $${trimNumber(data.credit_limit)})` : ""}`); + printHints(out, data.hints); + return 0; +} + +/** Shows at most a quarter of the key (capped at 6 chars) so short keys are not mostly revealed. */ +export function maskKey(key: string): string { + const visible = Math.min(6, Math.floor(key.length / 4)); + return visible >= 2 ? `${key.slice(0, Math.ceil(visible / 2) + 1)}…${key.slice(-Math.floor(visible / 2))}` : "…"; +} +async function readStdin(): Promise { + if (process.stdin.isTTY) return ""; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +async function keys(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Pick): Promise { + const [sub, ...rest] = argv; + if (sub === "add") { + const { values } = parse(rest, { key: { type: "string", short: "k" }, url: { type: "string" } }); + // `-k -` (or no -k with piped stdin) keeps the key out of shell history, `ps`, and agent transcripts. + const key = (values.key === "-" || (values.key === undefined && deps.stdin) ? await (deps.stdin ?? readStdin)() : values.key ?? "").trim(); + if (!key) throw new SandBaseError("USAGE", "Pass the API key on stdin (`sandbase keys add -k -`) or with -k.", `Create one at ${keysUrl(env)}. Never paste API keys into an AI chat.`); + if (values.url && !validMcpUrl(values.url)) throw new SandBaseError("USAGE", "--url must be an http(s) URL ending in /v1/mcp."); + await atomicWrite(keyFilePath(env), JSON.stringify({ key, ...(values.url ? { mcpUrl: values.url } : {}), addedAt: new Date().toISOString() }, null, 2) + "\n", 0o600); + const masked = maskKey(key); + if (out.json) out.data({ saved: true, key: masked, path: keyFilePath(env) }); else out.print(`Saved API key ${masked} to ${keyFilePath(env)} (mode 600). Verify with: sandbase balance`); + return 0; + } + if (sub === "list") { + parse(rest, {}); + const stored = await readStoredKey(env); + const entries = [...(env.SANDBASE_API_KEY ? [{ source: "env:SANDBASE_API_KEY", key: maskKey(env.SANDBASE_API_KEY), active: true }] : []), ...(stored ? [{ source: "keys", key: maskKey(stored.key), active: !env.SANDBASE_API_KEY }] : [])]; + if (out.json) { out.data({ keys: entries }); return 0; } + out.print(entries.length ? table(["SOURCE", "KEY", "ACTIVE"], entries.map(entry => [entry.source, entry.key, entry.active ? "yes" : ""])) : `No API key stored. Connected clients from \`${CONNECT_COMMAND}\` are used automatically.`); + return 0; + } + if (sub === "remove") { + parse(rest, {}); + await rm(keyFilePath(env), { force: true }); + if (out.json) out.data({ removed: true }); else out.print("Removed the stored API key."); + return 0; + } + throw new SandBaseError("USAGE", "Use `sandbase keys add -k `, `sandbase keys list`, or `sandbase keys remove`."); +} + +export async function runAgentCommand(command: AgentCommand, argv: string[], deps: AgentDeps = {}): Promise { + const env = deps.env || process.env; + const json = argv.includes("-j") || argv.includes("--json"); + const out = new Output(json, deps.out || (text => process.stdout.write(`${text}\n`)), deps.err || (text => process.stderr.write(`${text}\n`))); + const timing = { sleep: deps.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))), now: deps.now || Date.now }; + const pendingNotice = command === "keys" ? Promise.resolve(undefined) : updateNotice(env); + if (argv.includes("-h") || argv.includes("--help")) { out.print(agentUsage); return 0; } + let code: number; + try { + const notice = async () => { const text = await Promise.race([pendingNotice, new Promise(resolve => setTimeout(() => resolve(undefined), 2500).unref())]); if (text) out.notice(text); }; + if (json) await notice(); + code = command === "discover" ? await discover(argv, out, env, deps.fetchImpl) + : command === "inspect" ? await inspect(argv, out, env, deps.fetchImpl) + : command === "run" ? await run(argv, out, env, timing, deps.fetchImpl) + : command === "runs" ? await runs(argv, out, env, timing, deps.fetchImpl) + : command === "balance" ? await balance(argv, out, env, deps.fetchImpl) + : await keys(argv, out, env, deps); + if (!json) await notice(); + } catch (error) { + const failure = error instanceof SandBaseError ? error : new SandBaseError("TOOL_ERROR", (error instanceof Error ? error.message : "Unknown failure").replace(/(?:sk|cln)-[A-Za-z0-9_-]+/g, "[REDACTED]")); + out.error(failure); + code = failure.code === "USAGE" ? 2 : 1; + } + return code; +} diff --git a/src/agent/probe.ts b/src/agent/probe.ts new file mode 100644 index 0000000..9a43b94 --- /dev/null +++ b/src/agent/probe.ts @@ -0,0 +1,29 @@ +// Real connectivity check used by `sandbase doctor` and `sandbase setup`: +// initialize → tools/list → sandbase_account (free) against the same endpoint +// candidates the bridge uses. Local file checks alone cannot tell "configured" +// from "configured but unreachable", which is the failure agents actually hit. +import { endpointCandidates, SandBaseError, SandBaseMCPClient, type FetchLike } from "./client.js"; +import { cliVersion } from "./update.js"; + +export interface ProbeResult { ok: boolean; line: string } + +export async function probeEndpoint(label: string, credential: string, mcpUrl: string, env = process.env, fetchImpl?: FetchLike, timeoutMs = 15_000): Promise { + const client = new SandBaseMCPClient({ credential, urls: endpointCandidates(mcpUrl, env), source: label }, fetchImpl, timeoutMs, env); + const started = Date.now(); + try { + await client.rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "sandbase-doctor", version: cliVersion() } }); + const listed = await client.rpc("tools/list", {}) as { tools?: unknown[] }; + const account = await client.tool("sandbase_account", {}); + const host = new URL(client.activeUrl()).host; + const primaryHost = new URL(mcpUrl).host; + const primaryFailure = client.attempts.find(attempt => new URL(attempt.url).host === primaryHost && !attempt.outcome.startsWith("HTTP")); + const fallback = host !== primaryHost && primaryFailure ? `, primary=${primaryHost} (${primaryFailure.outcome}), fallback=${host} (the MCP bridge switches automatically)` : ""; + const tools = Array.isArray(listed?.tools) ? listed.tools.length : 0; + const balance = account.data.balance === undefined ? "unknown" : `$${String(account.data.balance)}`; + const low = Number(account.data.balance) <= 0 ? ", next_step=Balance is empty; top up before running paid tools." : ""; + return { ok: tools > 0, line: `${label}: network=ok, endpoint=${host}, latency=${Date.now() - started}ms, tools=${tools}, balance=${balance}${fallback}${low}` }; + } catch (error) { + const failure = error instanceof SandBaseError ? error : new SandBaseError("NETWORK", error instanceof Error ? error.message : "unknown failure"); + return { ok: false, line: `${label}: network=failed, code=${failure.code}, reason=${failure.message}${failure.hint ? `, next_step=${failure.hint}` : ""}` }; + } +} diff --git a/src/agent/update.ts b/src/agent/update.ts new file mode 100644 index 0000000..c638018 --- /dev/null +++ b/src/agent/update.ts @@ -0,0 +1,48 @@ +// Once-a-day npm version check. The notice is printed to stderr in text mode and +// appended to `notes` in JSON mode, because agents that parse JSON never read +// stderr — and the notice is what tells them to refresh the Skill too. +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { atomicWrite } from "../fs-safe.js"; +import { sandbaseHome } from "../paths.js"; + +export const SKILL_URL = "https://www.sandbase.ai/skill.md"; +const DAY_MS = 24 * 60 * 60 * 1000; + +export function cliVersion(): string { + for (const relative of ["../../package.json", "../../../package.json"]) { + try { const value = JSON.parse(readFileSync(fileURLToPath(new URL(relative, import.meta.url)), "utf8")) as { name?: string; version?: string }; if (value.name === "@sandbaseai/cli" && value.version) return value.version; } catch { /* try the next layout */ } + } + return "0.0.0"; +} + +export function compareVersions(a: string, b: string): number { + const parse = (value: string) => value.replace(/^v/, "").split("-")[0]!.split(".").map(part => Number.parseInt(part, 10) || 0); + const left = parse(a), right = parse(b); + for (let i = 0; i < Math.max(left.length, right.length); i++) { const diff = (left[i] || 0) - (right[i] || 0); if (diff) return Math.sign(diff); } + return 0; +} + +interface Cache { checkedAt: number; latest?: string } +function cachePath(env: NodeJS.ProcessEnv): string { return join(sandbaseHome(env), "update-check.json"); } +function disabled(env: NodeJS.ProcessEnv): boolean { return env.SANDBASE_NO_UPDATE_CHECK === "1" || env.CI === "true" || env.NODE_ENV === "test"; } + +/** Starts (at most daily) a background registry check; resolves to an update notice or undefined. Never throws. */ +export async function updateNotice(env = process.env, fetchImpl: typeof fetch = globalThis.fetch, now = Date.now()): Promise { + if (disabled(env)) return undefined; + const current = cliVersion(); + let cache: Cache | undefined; + try { cache = JSON.parse(await readFile(cachePath(env), "utf8")) as Cache; } catch { /* first run */ } + let latest = cache?.latest; + if (!cache || now - cache.checkedAt > DAY_MS) { + try { + const response = await fetchImpl("https://registry.npmjs.org/@sandbaseai/cli/latest", { signal: AbortSignal.timeout(2000) }); + if (response.ok) { const value = await response.json() as { version?: string }; if (typeof value.version === "string") latest = value.version; } + } catch { /* offline: keep the cached answer */ } + try { await atomicWrite(cachePath(env), JSON.stringify({ checkedAt: now, ...(latest ? { latest } : {}) }) + "\n", 0o600); } catch { /* read-only home */ } + } + if (!latest || compareVersions(latest, current) <= 0) return undefined; + return `Update available: @sandbaseai/cli ${current} → ${latest}. Run \`npx -y @sandbaseai/cli@latest setup\` (or \`npm install -g @sandbaseai/cli@latest && sandbase setup\`) to update the CLI, the local MCP bridge, and the SandBase Skill. Latest Skill: ${SKILL_URL}`; +} diff --git a/src/cli.ts b/src/cli.ts index c4b0af8..442aad7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,26 +1,48 @@ #!/usr/bin/env node -import { connect, doctor, unregister } from "./commands.js"; +import { connect, doctor, setup, unregister } from "./commands.js"; import { clientList } from "./clients.js"; import { bridgeClients, isBridgeClient, isClient, type BridgeClient, type ConnectClient } from "./types.js"; import { stableCatalog } from "./catalog.js"; +import { agentUsage, isAgentCommand, runAgentCommand } from "./agent/commands.js"; +import { cliVersion } from "./agent/update.js"; if (Number(process.versions.node.split(".")[0]) < 20) { console.error("SandBase CLI requires Node.js 20 or newer. Upgrade Node.js and retry."); process.exit(1); } -const [command, ...args] = process.argv.slice(2); const at = args.indexOf("--client"); const value = at >= 0 ? args[at + 1] : undefined; -const client: ConnectClient | undefined = value === undefined || value === "auto" ? "auto" : isClient(value) ? value : undefined; -const bridgeClient: BridgeClient | undefined = value !== undefined && isBridgeClient(value) ? value : undefined; -if (command === "catalog") { - if (args.length !== 1 || args[0] !== "--json") { console.error("Usage: sandbase catalog --json"); process.exit(2); } -} else if (command === "mcp-bridge") { - if (!bridgeClient || at !== 0 || args.length !== 2) { console.error(`Usage: sandbase mcp-bridge --client <${bridgeClients.join("|")}>`); process.exit(2); } -} else if (!command || !client || !["connect", "doctor", "unregister"].includes(command) || (at >= 0 && !value)) { console.error(`Usage: sandbase [--client <${clientList()}|auto>]`); process.exit(2); } -const controller = new AbortController(); const signals = ["SIGINT", "SIGTERM"] as const; const abort = () => controller.abort(); for (const signal of signals) process.once(signal, abort); -try { - if (command === "catalog") process.stdout.write(stableCatalog()); - else if (command === "mcp-bridge") { - const runtime = await import(new URL("../assets/mcp-bridge.mjs", import.meta.url).href) as { runMCPBridge(options: { client: BridgeClient; signal: AbortSignal }): Promise }; - await runtime.runMCPBridge({ client: bridgeClient!, signal: controller.signal }); - } else if (command === "connect") await connect(client, { signal: controller.signal }); - else if (command === "doctor") { if (!(await doctor(client))) process.exitCode = 1; } - else await unregister(client); -} catch (e) { const message = e instanceof Error ? e.message : "Unknown failure"; if (message.includes("fetch failed")) console.error("Authorization response may have been lost. Check SandBase Dashboard for a newly created CLI credential, revoke it if present, then retry."); else console.error(message.replace(/(?:sk|cln)-[A-Za-z0-9_-]+/g, "[REDACTED]")); process.exitCode = 1; } -finally { for (const signal of signals) process.removeListener(signal, abort); } +const [command, ...args] = process.argv.slice(2); +const usage = `Usage: + sandbase connect [--client |auto] Sign in once in the browser and configure your AI clients + sandbase setup Refresh the local MCP bridge and SandBase Skill after an update, then test connectivity + sandbase doctor [--client ] Check local configuration and make one real round trip to SandBase + sandbase unregister [--client ] Remove SandBase-managed local state + sandbase --version + +${agentUsage} + +Clients: ${clientList()}`; + +if (command === "--version" || command === "-v" || command === "version") { console.log(cliVersion()); process.exit(0); } +if (!command || command === "--help" || command === "-h" || command === "help") { console.log(usage); process.exit(command ? 0 : 2); } +if (isAgentCommand(command)) { process.exitCode = await runAgentCommand(command, args); } +else { + const at = args.indexOf("--client"); const value = at >= 0 ? args[at + 1] : undefined; + const client: ConnectClient | undefined = value === undefined || value === "auto" ? "auto" : isClient(value) ? value : undefined; + const bridgeClient: BridgeClient | undefined = value !== undefined && isBridgeClient(value) ? value : undefined; + if (command === "catalog") { + if (args.length !== 1 || args[0] !== "--json") { console.error("Usage: sandbase catalog --json"); process.exit(2); } + } else if (command === "mcp-bridge") { + if (!bridgeClient || at !== 0 || args.length !== 2) { console.error(`Usage: sandbase mcp-bridge --client <${bridgeClients.join("|")}>`); process.exit(2); } + } else if (command === "setup") { + // --client/--email are accepted for parity with agent setup instructions; the CLI never asks for them. + } else if (!client || !["connect", "doctor", "unregister"].includes(command) || (at >= 0 && !value)) { console.error(`Usage: sandbase [--client <${clientList()}|auto>]\nRun \`sandbase --help\` for agent commands (discover, inspect, run, runs, balance, keys).`); process.exit(2); } + const controller = new AbortController(); const signals = ["SIGINT", "SIGTERM"] as const; const abort = () => controller.abort(); for (const signal of signals) process.once(signal, abort); + try { + if (command === "catalog") process.stdout.write(stableCatalog()); + else if (command === "mcp-bridge") { + const runtime = await import(new URL("../assets/mcp-bridge.mjs", import.meta.url).href) as { runMCPBridge(options: { client: BridgeClient; signal: AbortSignal }): Promise }; + await runtime.runMCPBridge({ client: bridgeClient!, signal: controller.signal }); + } else if (command === "setup") { if (!(await setup())) process.exitCode = 1; } + else if (command === "connect") await connect(client, { signal: controller.signal }); + else if (command === "doctor") { if (!(await doctor(client))) process.exitCode = 1; } + else await unregister(client); + } catch (e) { const message = e instanceof Error ? e.message : "Unknown failure"; if (message.includes("fetch failed")) console.error("Authorization response may have been lost. Check SandBase Dashboard for a newly created CLI credential, revoke it if present, then retry."); else console.error(message.replace(/(?:sk|cln)-[A-Za-z0-9_-]+/g, "[REDACTED]")); process.exitCode = 1; } + finally { for (const signal of signals) process.removeListener(signal, abort); } +} diff --git a/src/commands.ts b/src/commands.ts index f03f81d..20eb370 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,6 +19,7 @@ import { claudeDesktopArtifactPath, readClaudeDesktopArtifactState, removeClaude import { coworkStatePath, readCoworkState, removeCoworkState, rollbackCoworkState } from "./cowork.js"; import { atomicWrite, readOptional, restore } from "./fs-safe.js"; import { configPath } from "./paths.js"; +import { probeEndpoint } from "./agent/probe.js"; const sleep = (ms: number, signal?: AbortSignal) => new Promise((resolve, reject) => { const t = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("Authorization cancelled")); }, { once: true }); }); type Detection = { installed: boolean; detail: string }; @@ -381,6 +382,49 @@ export async function doctor(client: ConnectClient = "auto", store: CredentialSt console.log(`${target}: status=${status}, mcp=${configured ? "configured" : "not_configured"}, skill=${skill}, invocation=${capability.invocation}, verification=${capability.verification}, shared_skill_references=${references.join(",") || "none"}, next_step=${skillDetail}`); healthy = healthy && detected.installed && !!credential && configured && !["missing", "modified"].includes(skill); } + if (!(await probeStoredCredentials(targets, store))) healthy = false; + return healthy; +} + +/** + * Live runtime checks: flags an outdated shared bridge and makes one real + * authenticated round trip per distinct stored credential. Skipped with + * SANDBASE_DOCTOR_NETWORK=0 (offline fixtures and tests). + */ +async function probeStoredCredentials(targets: Client[], store: CredentialStore, env = process.env): Promise { + if (env.SANDBASE_DOCTOR_NETWORK === "0") return true; + let ok = true; + if (await inspectBridge() === "invalid") { console.log("bridge: status=outdated, next_step=The local MCP bridge is from another CLI version. Run `npx -y @sandbaseai/cli@latest setup` to refresh it and the SandBase Skill, then restart your AI client."); ok = false; } + const seen = new Set(); + for (const target of targets) { + let record: CredentialRecord | undefined; + try { record = await store.get(target); } catch { continue; } + if (!record?.credential || !record.mcpUrl || seen.has(record.credential)) continue; + seen.add(record.credential); + const result = await probeEndpoint(target, record.credential, record.mcpUrl, env); + console.log(result.line); ok = ok && result.ok; + } + return ok; +} + +const refreshableSkillClients: Client[] = ["claude-code", "cursor", "hermes", "kiro"]; +/** + * `sandbase setup`: refresh everything a newer CLI ships without a new browser + * sign-in — the shared MCP bridge and every SandBase-owned Skill copy — then + * verify connectivity. User-modified Skills are never overwritten. + */ +export async function setup(store: CredentialStore = new FileCredentialStore(), log: (line: string) => void = console.log): Promise { + let healthy = true; + const connected: Client[] = []; + for (const client of clients) { try { const record = await store.get(client); if (record?.credential) connected.push(client); } catch { /* unreadable store is reported below */ } } + if (connected.length) { const bridge = await installBridge(); log(`bridge: status=${bridge.changed ? "updated" : "current"}, path=${bridge.path}`); } + else log(`bridge: status=skipped, next_step=No connected client yet. Run \`npx -y @sandbaseai/cli@latest connect\` to sign in once in the browser.`); + for (const client of refreshableSkillClients) { + const state = await inspectSkill(client); + if (state === "installed") { try { const result = await installSkill(client); log(`${client}: skill=${result.changed ? "updated" : "current"}`); } catch (error) { healthy = false; log(`${client}: skill=failed, reason=${safeFailure(error)}`); } } + else if (state === "modified") log(`${client}: skill=modified, next_step=A locally edited SandBase Skill was left untouched; replace it with https://www.sandbase.ai/skill.md if you want the latest guidance.`); + } + if (connected.length && !(await probeStoredCredentials(connected, store))) healthy = false; return healthy; } diff --git a/test/agent.test.ts b/test/agent.test.ts new file mode 100644 index 0000000..9203f22 --- /dev/null +++ b/test/agent.test.ts @@ -0,0 +1,310 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import { endpointCandidates, httpError, resolveEndpoint, SandBaseError, SandBaseMCPClient, type FetchLike } from "../src/agent/client.js"; +import { formatPrice, resolveSchema, runAgentCommand } from "../src/agent/commands.js"; +import { probeEndpoint } from "../src/agent/probe.js"; +import { compareVersions, updateNotice } from "../src/agent/update.js"; + +type Reply = { status?: number; body: unknown; contentType?: string }; +type Call = { url: string; method: string; params: Record | undefined; authorization: string | undefined }; +function connectTimeout(): Error { const error = new TypeError("fetch failed") as TypeError & { cause?: unknown }; error.cause = { code: "UND_ERR_CONNECT_TIMEOUT" }; return error; } +function fakeFetch(handler: (call: Call) => Reply | Error): { fetch: FetchLike; calls: Call[] } { + const calls: Call[] = []; + const fetch: FetchLike = async (url, init) => { + const request = JSON.parse(init.body) as { id: number; method: string; params?: Record }; + const call = { url, method: request.method, params: request.params, authorization: init.headers.authorization }; + calls.push(call); + const reply = handler(call); + if (reply instanceof Error) throw reply; + const body = typeof reply.body === "string" ? reply.body : JSON.stringify({ jsonrpc: "2.0", id: request.id, ...(reply.body as object) }); + return { ok: (reply.status ?? 200) < 300, status: reply.status ?? 200, headers: { get: (name: string) => name === "content-type" ? reply.contentType ?? "application/json" : null }, text: async () => body }; + }; + return { fetch, calls }; +} +const toolResult = (data: unknown): Reply => ({ body: { result: { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data, isError: false } } }); +async function home(): Promise { const root = await mkdtemp(join(tmpdir(), "sandbase-agent-")); return { HOME: root, SANDBASE_HOME: join(root, ".sandbase"), SANDBASE_NO_UPDATE_CHECK: "1" }; } +async function connected(env: NodeJS.ProcessEnv, client = "codex", mcpUrl = "https://api.sandbase.ai/v1/mcp"): Promise { + await mkdir(env.SANDBASE_HOME!, { recursive: true }); + await writeFile(join(env.SANDBASE_HOME!, "credentials.json"), JSON.stringify({ [client]: { credential: "test-credential-value", credentialId: "k", keyPrefix: "p", client, scope: ["mcp:invoke"], mcpUrl, createdAt: "now" } })); +} +async function cli(command: Parameters[0], argv: string[], env: NodeJS.ProcessEnv, fetch: FetchLike) { + const out: string[] = [], err: string[] = []; + let clock = 0; + const code = await runAgentCommand(command, argv, { env, fetchImpl: fetch, out: text => void out.push(text), err: text => void err.push(text), sleep: async ms => { clock += ms; }, now: () => clock }); + return { code, out: out.join("\n"), err: err.join("\n") }; +} + +test("credential precedence: env key, then stored key, then a connected client", async () => { + const env = await home(); + await assert.rejects(resolveEndpoint({}, env), (error: SandBaseError) => error.code === "AUTH_REQUIRED" && /connect/.test(error.hint ?? "")); + await connected(env, "claude-code"); + assert.equal((await resolveEndpoint({}, env)).source, "client:claude-code"); + await writeFile(join(env.SANDBASE_HOME!, "cli-key.json"), JSON.stringify({ key: "stored-key", addedAt: "now" })); + assert.equal((await resolveEndpoint({}, env)).credential, "stored-key"); + assert.equal((await resolveEndpoint({ client: "claude-code" }, env)).source, "client:claude-code"); + assert.equal((await resolveEndpoint({}, { ...env, SANDBASE_API_KEY: "env-key" })).credential, "env-key"); + await assert.rejects(resolveEndpoint({ client: "cursor" }, env), /No SandBase credential is stored for client "cursor"/); +}); + +test("the public API host gets the Cloudflare mirror as a fallback; overrides are honored", () => { + assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", {}), ["https://api.sandbase.ai/v1/mcp", "https://www.sandbase.ai/v1/mcp"]); + assert.deepEqual(endpointCandidates("http://127.0.0.1:9/v1/mcp", {}), ["http://127.0.0.1:9/v1/mcp"]); + assert.deepEqual(endpointCandidates("https://api.sandbase.ai/v1/mcp", { SANDBASE_MCP_FALLBACK_URLS: "" }), ["https://api.sandbase.ai/v1/mcp"]); +}); + +test("an unreachable primary falls back to the mirror and the preference persists across processes", async () => { + const env = await home(); await connected(env); + const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : toolResult({ balance: "1.5", currency: "USD" })); + const first = new SandBaseMCPClient(await resolveEndpoint({}, env), fetch, 1000, env); + assert.equal((await first.tool("sandbase_account")).data.balance, "1.5"); + assert.deepEqual(calls.map(call => new URL(call.url).host), ["api.sandbase.ai", "www.sandbase.ai"]); + const second = new SandBaseMCPClient(await resolveEndpoint({}, env), fetch, 1000, env); + await second.tool("sandbase_account"); + assert.equal(new URL(calls.at(-1)!.url).host, "www.sandbase.ai"); assert.equal(calls.length, 3, "second process skips the dead primary"); +}); + +test("network and HTTP failures carry a code and a next step", async () => { + const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); + const { fetch } = fakeFetch(() => connectTimeout()); + const result = await cli("balance", [], env, fetch); + assert.equal(result.code, 1); assert.match(result.err, /Error \[NETWORK\]: Cannot reach SandBase \(api\.sandbase\.ai: connection timed out; www\.sandbase\.ai: connection timed out\)/); assert.match(result.err, /Next: .*sandbase doctor/); + assert.equal(httpError(402, '{"error":"API key spending limit exceeded"}', "api.sandbase.ai").code, "INSUFFICIENT_BALANCE"); + assert.match(httpError(401, "", "api.sandbase.ai").hint ?? "", /connect/); + const json = await cli("balance", ["-j"], env, fetch); + assert.equal(JSON.parse(json.out).error.code, "NETWORK"); assert.doesNotMatch(json.out + json.err, /test-credential-value/); +}); + +test("discover renders a table with price and next step; zero results show server hints", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const { fetch, calls } = fakeFetch(call => toolResult(call.params?.arguments && (call.params.arguments as { q?: string }).q === "none" ? { data: [], count: 0, hints: ["Try different keywords."] } : { data: [{ tool_name: "sandbase_flux", category: "multimodal", vendor: "BFL", pricing: { base_price: "0.0480" }, run_count: 12, description: "Text to image" }], count: 1 })); + const found = await cli("discover", ["-q", "image generation", "-l", "5"], env, fetch); + assert.equal(found.code, 0); assert.match(found.out, /sandbase_flux\s+multimodal\s+BFL\s+\$0\.048\/call/); assert.match(found.out, /Next: sandbase inspect/); + assert.deepEqual(calls[0]!.params, { name: "sandbase_discover", arguments: { q: "image generation", limit: 5 } }); + assert.equal(calls[0]!.authorization, "Bearer test-credential-value"); + const none = await cli("discover", ["none"], env, fetch); + assert.match(none.out, /No tools matched "none"/); assert.match(none.out, /Try different keywords/); + assert.equal((await cli("discover", [], env, fetch)).code, 2); +}); + +test("inspect resolves $ref schemas into fields and a runnable example", async () => { + const schema = { $ref: "#/components/schemas/GenerationRequest", components: { schemas: { GenerationRequest: { type: "object", required: ["keyword"], properties: { keyword: { type: "string", description: "Search keyword", example: "OpenAI" }, search_type: { type: "string", default: "Top" } } } } } }; + assert.deepEqual(Object.keys((resolveSchema(schema).properties ?? {}) as object), ["keyword", "search_type"]); + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const { fetch } = fakeFetch(() => toolResult({ tool_name: "sandbase_twitter_search", model: "twitter/search", category: "api", description: "Search posts", pricing: { base_price: "0" }, inputSchema: schema })); + const text = await cli("inspect", ["sandbase_twitter_search"], env, fetch); + assert.match(text.out, /keyword\s+string\s+yes\s+Search keyword/); assert.match(text.out, /sandbase run sandbase_twitter_search -i '\{"keyword":"OpenAI"\}'/); + const json = JSON.parse((await cli("inspect", ["sandbase_twitter_search", "-j"], env, fetch)).out); + assert.deepEqual(json.example_arguments, { keyword: "OpenAI" }); assert.equal(json.inputSchema.type, "object"); + assert.equal(formatPrice({ input_per_million: "2.500000", output_per_million: "10.000000" }), "$2.5/$10 per 1M tok"); +}); + +test("run -w polls an async run to completion, fetches its output, and -o keeps it out of stdout", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + let polls = 0; + const { fetch, calls } = fakeFetch(call => { + const name = (call.params as { name?: string } | undefined)?.name; + if (call.method === "tools/call" && name === "sandbase_run") return toolResult({ prediction_id: "pred_1", status: "running", output: null, taskId: "pred_1" }); + if (call.method === "tools/call" && name === "sandbase_run_get") return toolResult({ run_id: "pred_1", model: "kling/video", status: ++polls < 3 ? "running" : "completed", cost: "0.2000" }); + if (call.method === "tasks/result") return { body: { result: { content: [{ type: "text", text: "" }], structuredContent: { prediction_id: "pred_1", status: "completed", output: [{ url: "https://cdn.test/video.mp4" }] } } } }; + return { body: { error: { code: -32601, message: "unexpected" } } }; + }); + const file = join(env.HOME!, "result.json"); + const result = await cli("run", ["sandbase_kling_video", "-i", '{"prompt":"a cat"}', "-w", "-o", file, "-j"], env, fetch); + assert.equal(result.code, 0, result.err); + const view = JSON.parse(result.out); + assert.equal(view.status, "completed"); assert.equal(view.cost, "0.2000"); assert.equal(view.output_file, file); assert.equal(view.output, undefined); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")), [{ url: "https://cdn.test/video.mp4" }]); + assert.deepEqual(calls[0]!.params, { name: "sandbase_run", arguments: { name: "sandbase_kling_video", arguments: { prompt: "a cat" } } }); + assert.ok(calls.some(call => call.method === "tasks/result")); +}); + +test("a run still going at --timeout prints the resume command; a failed run exits 1", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const running = fakeFetch(call => toolResult((call.params as { name?: string }).name === "sandbase_run" ? { prediction_id: "pred_2", status: "running" } : { run_id: "pred_2", status: "running" })); + const slow = await cli("run", ["sandbase_video", "-i", "{}", "-w", "--timeout", "5"], env, running.fetch); + assert.equal(slow.code, 0); assert.match(slow.out, /still running when --timeout elapsed/); assert.match(slow.out, /Next: sandbase runs get -r pred_2 -w/); + const failing = fakeFetch(call => call.method === "tasks/result" ? { body: { result: { structuredContent: { status: "failed", error: { message: "content policy" } } } } } : toolResult((call.params as { name?: string }).name === "sandbase_run" ? { prediction_id: "pred_3", status: "running" } : { run_id: "pred_3", status: "failed" })); + const failed = await cli("runs", ["get", "-r", "pred_3"], env, failing.fetch); + assert.equal(failed.code, 1); assert.match(failed.out, /Status: failed/); assert.match(failed.out, /content policy/); + const bad = await cli("run", ["sandbase_video", "-i", "not json"], env, running.fetch); + assert.equal(bad.code, 2); assert.match(bad.err, /not valid JSON/); +}); + +test("tool errors from the server are mapped to actionable hints", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const { fetch } = fakeFetch(() => ({ body: { error: { code: -32600, message: "invalid service request" } } })); + const result = await cli("inspect", ["nope"], env, fetch); + assert.equal(result.code, 1); assert.match(result.err, /\[NOT_FOUND\]/); assert.match(result.err, /sandbase discover -q/); +}); + +test("keys add stores a 0600 key used before client records", async () => { + const env = await home(); await connected(env); + const added = await cli("keys", ["add", "-k", "sb-test-1234567890"], env, fakeFetch(() => toolResult({})).fetch); + assert.equal(added.code, 0); assert.doesNotMatch(added.out, /sb-test-1234567890/); + assert.equal((await resolveEndpoint({}, env)).source, "keys"); + assert.equal((await cli("keys", ["remove"], env, fakeFetch(() => toolResult({})).fetch)).code, 0); + assert.equal((await resolveEndpoint({}, env)).source, "client:codex"); +}); + +test("doctor probe reports the mirror and balance, or the exact failure", async () => { + const ok = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover" }] } } } : call.method === "initialize" ? { body: { result: {} } } : toolResult({ balance: "3.2" })); + const env = await home(); + const good = await probeEndpoint("codex", "secret", "https://api.sandbase.ai/v1/mcp", env, ok.fetch, 1000); + assert.equal(good.ok, true); assert.match(good.line, /network=ok, endpoint=www\.sandbase\.ai/); assert.match(good.line, /primary=api\.sandbase\.ai \(connection timed out\)/); assert.match(good.line, /balance=\$3\.2/); + const denied = fakeFetch(() => ({ status: 401, body: '{"error":"invalid API key"}' })); + const bad = await probeEndpoint("codex", "secret", "http://127.0.0.1:9/v1/mcp", env, denied.fetch, 1000); + assert.equal(bad.ok, false); assert.match(bad.line, /network=failed, code=AUTH_FAILED/); assert.match(bad.line, /next_step=.*connect/); assert.doesNotMatch(bad.line, /secret/); +}); + +test("update notice compares semver and is suppressed when disabled", async () => { + assert.equal(compareVersions("0.10.0", "0.9.9"), 1); assert.equal(compareVersions("v1.2.3", "1.2.3"), 0); assert.equal(compareVersions("0.2.0", "0.2.1"), -1); + const env = { ...(await home()), SANDBASE_NO_UPDATE_CHECK: "" , CI: "", NODE_ENV: "" }; + const registry = (async () => ({ ok: true, json: async () => ({ version: "99.0.0" }) })) as unknown as typeof fetch; + assert.match((await updateNotice(env, registry)) ?? "", /Update available: @sandbaseai\/cli .* → 99\.0\.0.*sandbase setup.*skill\.md/); + assert.equal(await updateNotice({ ...env, SANDBASE_NO_UPDATE_CHECK: "1" }, registry), undefined); +}); + +async function bridge(env: NodeJS.ProcessEnv, fetch: FetchLike, lines: unknown[]): Promise>> { + const runtime = await import(pathToFileURL(join(process.cwd(), "assets", "mcp-bridge.mjs")).href) as { runMCPBridge(options: Record): Promise }; + const input = new PassThrough(), output = new PassThrough(); let text = ""; + output.on("data", chunk => { text += chunk; }); + const done = runtime.runMCPBridge({ client: "codex", env, input, output, fetchImpl: fetch }); + input.end(lines.map(line => JSON.stringify(line)).join("\n") + "\n"); + await done; + return text.trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); +} + +test("bridge keeps the server usable offline and turns tool failures into visible errors", async () => { + const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); + const offline = fakeFetch(() => connectTimeout()); + const [init, list, call] = await bridge(env, offline.fetch, [{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } }, { jsonrpc: "2.0", id: 2, method: "tools/list" }, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "sandbase_discover", arguments: { q: "x" } } }]); + assert.equal(init!.result.protocolVersion, "2025-06-18"); assert.match(init!.result.instructions, /unreachable/); + assert.ok(list!.result.tools.some((tool: { name: string }) => tool.name === "sandbase_discover")); + assert.equal(call!.result.isError, true); assert.match(call!.result.content[0].text, /unreachable from this machine \(api\.sandbase\.ai: connection timed out; www\.sandbase\.ai: connection timed out\).*sandbase doctor/); + + const mixed = fakeFetch(call => call.url.includes("api.sandbase.ai") ? connectTimeout() : call.method === "tools/list" ? { body: { result: { tools: [{ name: "sandbase_discover", description: "live" }] } } } : call.method === "tools/call" && (call.params as { name: string }).name === "sandbase_account" ? { status: 402, body: '{"error":"API key spending limit exceeded"}' } : { body: { error: { code: -32603, message: "capability call failed" } } }); + const [listed, failed, broke] = await bridge(env, mixed.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/list" }, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "sandbase_run", arguments: {} } }, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "sandbase_account", arguments: {} } }]); + assert.equal(listed!.result.tools[0].description, "live"); + assert.equal(failed!.result.isError, true); assert.match(failed!.result.content[0].text, /capability call failed\. The upstream provider failed\. Retry once/); + assert.equal(broke!.result.isError, true); assert.match(broke!.result.content[0].text, /\(402\): API key spending limit exceeded.*console\/billing/); + assert.ok(mixed.calls.filter(call => call.url.includes("api.sandbase.ai")).length <= 1, "after one fallback the session stays on the mirror"); + + const cachedOffline = await bridge(env, offline.fetch, [{ jsonrpc: "2.0", id: 9, method: "tools/list" }]); + assert.equal(cachedOffline[0]!.result.tools[0].description, "live", "offline tools/list serves the last live list"); +}); + +function resetAfterSend(): Error { const error = new TypeError("fetch failed") as TypeError & { cause?: unknown }; error.cause = { code: "ECONNRESET" }; return error; } + +test("a paid run interrupted after sending is never replayed on the mirror", async () => { + const env = await home(); await connected(env); + const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? resetAfterSend() : toolResult({ prediction_id: "pred_dup", status: "completed", output: "x" })); + const result = await cli("run", ["sandbase_paid", "-i", "{}", "-j"], env, fetch); + assert.equal(result.code, 1); + assert.equal(calls.filter(call => call.method === "tools/call").length, 1, "no second sandbase_run"); + assert.match(JSON.parse(result.out).error.message, /may have started a run/); + const reads = fakeFetch(call => call.url.includes("api.sandbase.ai") ? resetAfterSend() : toolResult({ balance: "2" })); + assert.equal((await cli("balance", [], await (async () => { const e = await home(); await connected(e); return e; })(), reads.fetch)).code, 0, "idempotent reads may fail over"); +}); + +test("HTTP 5xx on the primary fails over for idempotent calls only", async () => { + const env = await home(); await connected(env); + const { fetch, calls } = fakeFetch(call => call.url.includes("api.sandbase.ai") ? { status: 502, body: "bad gateway" } : toolResult({ balance: "4" })); + const ok = await cli("balance", [], env, fetch); + assert.equal(ok.code, 0); assert.match(ok.out, /\$4/); assert.equal(calls.length, 2); + const env2 = await home(); await connected(env2); + const paid = fakeFetch(call => call.url.includes("api.sandbase.ai") ? { status: 502, body: "" } : toolResult({ prediction_id: "p", status: "completed" })); + const failed = await cli("run", ["sandbase_paid", "-i", "{}"], env2, paid.fetch); + assert.equal(failed.code, 1); assert.equal(paid.calls.length, 1); assert.match(failed.err, /\[UPSTREAM\].*HTTP 502/); +}); + +test("completed runs with output null fetch tasks/result and never write null", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + let fetchedTask = false; + const { fetch } = fakeFetch(call => call.method === "tasks/result" ? (fetchedTask = true, { body: { result: { structuredContent: { status: "completed", output: [{ url: "https://cdn.test/a.png" }] } } } }) : toolResult({ run_id: "pred_n", status: "completed", output: null })); + const file = join(env.HOME!, "out.json"); + const result = await cli("runs", ["get", "-r", "pred_n", "-o", file, "-j"], env, fetch); + assert.equal(result.code, 0); assert.ok(fetchedTask); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")), [{ url: "https://cdn.test/a.png" }]); + const empty = fakeFetch(call => call.method === "tasks/result" ? { body: { result: { task: { status: "working" } } } } : toolResult({ run_id: "pred_e", status: "completed", output: null })); + const file2 = join(env.HOME!, "never.json"); + const none = JSON.parse((await cli("runs", ["get", "-r", "pred_e", "-o", file2, "-j"], env, empty.fetch)).out); + assert.equal(none.output_file, undefined); await assert.rejects(readFile(file2, "utf8"), /ENOENT/); +}); + +test("unknown statuses keep polling, plain-text sync results complete, server run_failed exits 1 with run id", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const queued = fakeFetch(() => toolResult({ prediction_id: "pred_q", status: "IN_QUEUE" })); + const q = await cli("run", ["sandbase_fal", "-i", "{}"], env, queued.fetch); + assert.equal(q.code, 0); assert.match(q.out, /Next: sandbase runs get -r pred_q -w/); + const text = fakeFetch(() => ({ body: { result: { content: [{ type: "text", text: "hello world" }], isError: false } } })); + const plain = await cli("run", ["sandbase_llm", "-i", "{}"], env, text.fetch); + assert.equal(plain.code, 0); assert.match(plain.out, /Status: completed/); assert.match(plain.out, /hello world/); + const failed = fakeFetch(() => ({ body: { result: { content: [{ type: "text", text: "The run failed: bad prompt" }], structuredContent: { error: { code: "run_failed", message: "The run failed: bad prompt", hint: "Fix the arguments" }, run_id: "pred_f" }, isError: true } } })); + const f = JSON.parse((await cli("run", ["sandbase_video", "-i", "{}", "-j"], env, failed.fetch)).out); + assert.equal(f.status, "failed"); assert.equal(f.run_id, "pred_f"); assert.match(f.error, /bad prompt/); +}); + +test("failures after a run started keep the run id; an unwritable -o fails before spending", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const { fetch, calls } = fakeFetch(call => (call.params as { name?: string }).name === "sandbase_run" ? toolResult({ prediction_id: "pred_k", status: "running" }) : connectTimeout()); + const lost = JSON.parse((await cli("run", ["sandbase_video", "-i", "{}", "-w", "-j"], env, fetch)).out); + assert.equal(lost.run_id, "pred_k"); assert.match(lost.error.hint, /sandbase runs get -r pred_k -w/); + const before = calls.length; + const bad = await cli("run", ["sandbase_video", "-i", "{}", "-o", join(env.HOME!, "missing-dir", "x.json")], env, fetch); + assert.equal(bad.code, 2); assert.match(bad.err, /nothing was run/); assert.equal(calls.length, before); +}); + +test("auth wording beats not-found wording; service runs print their idempotency key; keys can come from stdin", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const denied = fakeFetch(() => ({ body: { error: { code: -32600, message: "API key not found" } } })); + const auth = await cli("balance", [], env, denied.fetch); + assert.match(auth.err, /\[TOOL_ERROR\]/); assert.match(auth.err, /Sign in again/); + const svc = fakeFetch(() => toolResult({ service_id: "svc_demo", run_id: "run_1", status: "accepted" })); + const service = await cli("run", ["svc_demo", "-i", '{"input":"hi"}'], env, svc.fetch); + assert.match(service.out, /Idempotency key: cli-/); + const out: string[] = []; + const code = await runAgentCommand("keys", ["add", "-k", "-"], { env, out: text => void out.push(text), err: () => {}, stdin: async () => "sb-live-abcdefghijklmnopqrstuvwxyz\n" }); + assert.equal(code, 0); assert.doesNotMatch(out.join(""), /abcdefghijklmnop/); + assert.equal((await resolveEndpoint({}, env)).credential, "sb-live-abcdefghijklmnopqrstuvwxyz"); +}); + +test("bridge never replays sandbase_run, keeps the handshake alive on 5xx, and advertises a static offline list", async () => { + const env = await home(); await connected(env, "codex", "https://api.sandbase.ai/v1/mcp"); + const reset = fakeFetch(call => call.url.includes("api.sandbase.ai") ? resetAfterSend() : toolResult({ status: "completed" })); + const [run] = await bridge(env, reset.fetch, [{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sandbase_run", arguments: { name: "x", arguments: {} } } }]); + assert.equal(run!.result.isError, true); assert.match(run!.result.content[0].text, /Do not call sandbase_run again yet/); + assert.equal(reset.calls.length, 1); + const down = fakeFetch(() => ({ status: 503, body: "" })); + const [init, list] = await bridge(env, down.fetch, [{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, { jsonrpc: "2.0", id: 2, method: "tools/list" }]); + assert.equal(init!.result.capabilities.tools.listChanged, false); + assert.ok(list!.result.tools.some((tool: { name: string }) => tool.name === "sandbase_run_cancel")); +}); + +test("setup refreshes the bridge and SandBase-owned Skills but leaves edited Skills alone", async () => { + const env = await home(); + const previous = { HOME: process.env.HOME, SANDBASE_HOME: process.env.SANDBASE_HOME }; + process.env.HOME = env.HOME; process.env.SANDBASE_HOME = env.SANDBASE_HOME; + try { + const { createHash } = await import("node:crypto"); + const { setup } = await import("../src/commands.js"); + const { FileCredentialStore } = await import("../src/credentials/store.js"); + const old = "---\nname: sandbase\nversion: 0.1.0\n---\n\nold\n"; + const claudeDir = join(env.HOME!, ".claude", "skills", "sandbase"); await mkdir(claudeDir, { recursive: true }); + await writeFile(join(claudeDir, "SKILL.md"), old); + await writeFile(join(claudeDir, ".sandbase-managed.json"), JSON.stringify({ owner: "sandbase-cli", client: "claude-code", sha256: createHash("sha256").update(old).digest("hex") })); + const kiroDir = join(env.HOME!, ".kiro", "skills", "sandbase"); await mkdir(kiroDir, { recursive: true }); + await writeFile(join(kiroDir, "SKILL.md"), `${old}user edit\n`); + await writeFile(join(kiroDir, ".sandbase-managed.json"), JSON.stringify({ owner: "sandbase-cli", client: "kiro", sha256: createHash("sha256").update(old).digest("hex") })); + await connected(env); + const lines: string[] = []; + assert.equal(await setup(new FileCredentialStore(env.SANDBASE_HOME!), line => void lines.push(line)), true); + assert.match(lines.join("\n"), /bridge: status=updated/); assert.match(lines.join("\n"), /claude-code: skill=updated/); assert.match(lines.join("\n"), /kiro: skill=modified/); + assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.2\.0/); + assert.match(await readFile(join(kiroDir, "SKILL.md"), "utf8"), /user edit/); + } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } +}); diff --git a/test/package.test.ts b/test/package.test.ts index a482d4d..4aab7be 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -22,7 +22,8 @@ test("package declares the native Skill at the standard registry path", async () const skill = await readFile(join(process.cwd(), "skills", "sandbase", "SKILL.md"), "utf8"); assert.match(skill, /name: sandbase/); assert.match(skill, /sandbase-cli-managed: sandbase/); - assert.match(skill, /^disable-model-invocation: true$/m); + assert.doesNotMatch(skill, /disable-model-invocation/); + assert.match(skill, /Proactively search SandBase/); }); test("MCP Registry metadata exposes the authenticated remote endpoint", async () => { @@ -37,7 +38,7 @@ test("MCP Registry metadata exposes the authenticated remote endpoint", async () }; assert.equal(manifest.name, "io.github.sandbaseai/cli"); - assert.equal(manifest.version, "0.1.17"); + assert.equal(manifest.version, JSON.parse(await readFile(join(process.cwd(), "package.json"), "utf8")).version); assert.deepEqual(manifest.remotes, [ { type: "streamable-http", diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..3c445bf --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,4 @@ +// Loaded with `node --import` before every test file (and inherited by spawned +// CLI processes): tests must never reach the real SandBase API or npm registry. +process.env.SANDBASE_DOCTOR_NETWORK ??= "0"; +process.env.SANDBASE_NO_UPDATE_CHECK ??= "1"; diff --git a/test/skills.test.ts b/test/skills.test.ts index 9d0c52d..883efb5 100644 --- a/test/skills.test.ts +++ b/test/skills.test.ts @@ -20,7 +20,7 @@ test("Cursor and Codex converge on one owned shared native Skill", async () => { assert.equal(cursor.state, "configured"); assert.equal(codex.state, "already_configured"); assert.equal(skillPath("cursor", env), sharedSkillPath(env)); assert.equal(skillPath("codex", env), sharedSkillPath(env)); const content = await readFile(sharedSkillPath(env), "utf8"); - assert.match(content, /^name: sandbase$/m); assert.match(content, /^disable-model-invocation: true$/m); assert.match(content, /sandbase-cli-managed: sandbase/); + assert.match(content, /^name: sandbase$/m); assert.doesNotMatch(content, /disable-model-invocation/); assert.match(content, /sandbase-cli-managed: sandbase/); assert.doesNotMatch(content, /(?:sk-|cln-|authorization|credential|\/Users\/|C:\\Users\\)/i); const again = await installSkill("cursor", env); assert.equal(again.state, "already_configured"); assert.equal(again.changed, false); }); @@ -102,7 +102,7 @@ test("concurrent shared installs are idempotent and clean only verified legacy p await writeFile(legacy, await readFile(join(process.cwd(), "skills", "sandbase", "SKILL.md"), "utf8")); const results = await Promise.all([installSkill("cursor", env), installSkill("codex", env)]); assert.ok(results.some(result => result.state === "configured")); - assert.match(await readFile(sharedSkillPath(env), "utf8"), /disable-model-invocation: true/); + assert.match(await readFile(sharedSkillPath(env), "utf8"), /sandbase-cli-managed: sandbase/); await assert.rejects(() => readFile(legacy, "utf8")); });