diff --git a/package-lock.json b/package-lock.json index d84f07d..4a0fc8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sandbaseai/cli", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sandbaseai/cli", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { "jsonc-parser": "3.3.1", diff --git a/package.json b/package.json index 2b526c6..20fd894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sandbaseai/cli", - "version": "0.3.0", + "version": "0.3.1", "mcpName": "io.github.sandbaseai/cli", "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", diff --git a/server.json b/server.json index 2e78fa2..eb92352 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.3.0", + "version": "0.3.1", "repository": { "url": "https://github.com/sandbaseai/cli", "source": "github" @@ -26,7 +26,7 @@ { "registryType": "npm", "identifier": "@sandbaseai/cli", - "version": "0.3.0", + "version": "0.3.1", "packageArguments": [ { "type": "positional", diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index 93061f9..d2f65fe 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -1,6 +1,6 @@ --- name: sandbase -version: 0.3.0 +version: 0.3.1 description: >- Discover better ways to complete tasks. Proactively search SandBase (the sandbase_discover MCP tool, or `sandbase discover` in a shell) before writing a @@ -158,6 +158,22 @@ Tool failures come back as error results whose text names the cause **and the ne --- +## 7b. Health + +`discover` and `inspect` may include a `metrics` block per tool: `status` (healthy / stable / degraded / outage / unknown) plus `run_time_ms` (p50 and p95). The CLI shows a HEALTH column in discover and a Health line in inspect. + +| Status | Meaning | +|---|---| +| `healthy` | Confirmed working in the last few minutes | +| `stable` | No very recent sample, but a strong 24h record | +| `degraded` | Elevated failures — usually still works | +| `outage` | Not working right now | +| `unknown` *(or missing)* | Not enough data — never a warning | + +**Use health to break ties, never to filter.** Between two tools that both fit, prefer the healthier and faster one. Never skip a tool because its status is unknown or absent — that just means low traffic. Metrics are a platform-wide aggregate and can be briefly missing on a given call; if you need it, call again. + +--- + ## 8. Hints 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. diff --git a/skills/sandbase/skill.json b/skills/sandbase/skill.json index 9622f47..2bfec8e 100644 --- a/skills/sandbase/skill.json +++ b/skills/sandbase/skill.json @@ -1,6 +1,6 @@ { "name": "sandbase", - "version": "0.3.0", + "version": "0.3.1", "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", diff --git a/src/agent/commands.ts b/src/agent/commands.ts index dcefe2a..39bbd2c 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -79,6 +79,28 @@ function collectUrls(value: unknown, found = new Set()): Set { return found; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } +/** Verbose health line for inspect, e.g. "stable · 3.5s typical, 27.7s tail · 75 samples". */ +export function healthLine(metrics: unknown): string { + if (!metrics || typeof metrics !== "object") return ""; + const m = metrics as { status?: unknown; run_time_ms?: { p50?: unknown; p95?: unknown }; sample_size?: unknown }; + const status = typeof m.status === "string" ? m.status : ""; + if (!status) return ""; + const parts = [status]; + const p50 = m.run_time_ms && typeof m.run_time_ms.p50 === "number" ? m.run_time_ms.p50 : undefined; + const p95 = m.run_time_ms && typeof m.run_time_ms.p95 === "number" ? m.run_time_ms.p95 : undefined; + if (p50 !== undefined) parts.push(p95 !== undefined ? `${(p50 / 1000).toFixed(1)}s typical, ${(p95 / 1000).toFixed(1)}s tail` : `${(p50 / 1000).toFixed(1)}s typical`); + if (typeof m.sample_size === "number") parts.push(`${m.sample_size} samples`); + return parts.join(" · "); +} +/** Compact health cell for the discover table, e.g. "healthy 4.2s" or "stable". Blank when absent. */ +export function formatHealth(metrics: unknown): string { + if (!metrics || typeof metrics !== "object") return ""; + const m = metrics as { status?: unknown; run_time_ms?: { p50?: unknown } }; + const status = typeof m.status === "string" ? m.status : ""; + const p50 = m.run_time_ms && typeof m.run_time_ms.p50 === "number" ? m.run_time_ms.p50 : undefined; + if (!status) return ""; + return p50 !== undefined ? `${status} ${(p50 / 1000).toFixed(1)}s` : status; +} type OptionSpec = Record; const commonOptions = { json: { type: "boolean", short: "j" }, client: { type: "string" }, help: { type: "boolean", short: "h" } } as const; @@ -204,8 +226,8 @@ async function discover(argv: string[], out: Output, env: NodeJS.ProcessEnv, fet 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")); + 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), formatHealth(item.metrics), truncate(String(item.description ?? ""), 60)]); + out.print([`Found ${items.length} tool${items.length === 1 ? "" : "s"}${q ? ` for "${q}"` : ""}:`, "", table(["TOOL", "TYPE", "VENDOR", "PRICE", "HEALTH", "DESCRIPTION"], rows), "", "Next: sandbase inspect (input schema, exact price, and a ready-to-run command)", "HEALTH is advisory: prefer healthy/stable and lower latency to break ties; blank/unknown means no data, not unhealthy."].join("\n")); printHints(out, data.hints, data.warnings); return 0; } @@ -256,6 +278,8 @@ async function inspect(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetc 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}`]; + const health = healthLine(data.metrics); + if (health) lines.push(`Health: ${health}`); 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 })}`); diff --git a/test/agent.test.ts b/test/agent.test.ts index 60a6e20..296ef4f 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -79,6 +79,19 @@ test("network and HTTP failures carry a code and a next step", async () => { assert.equal(JSON.parse(json.out).error.code, "NETWORK"); assert.doesNotMatch(json.out + json.err, /test-credential-value/); }); +test("discover shows a HEALTH column and inspect shows a health line", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + const discover = fakeFetch(() => toolResult({ data: [{ tool_name: "sandbase_a", category: "api", vendor: "V", pricing: { base_price: "0.01" }, description: "x", metrics: { status: "stable", run_time_ms: { p50: 3543, p95: 27748 }, sample_size: 75 } }], count: 1 })); + const found = await cli("discover", ["-q", "a"], env, discover.fetch); + assert.match(found.out, /HEALTH/); assert.match(found.out, /stable 3\.5s/); assert.match(found.out, /break ties/); + const inspect = fakeFetch(() => toolResult({ tool_name: "sandbase_a", description: "x", pricing: { base_price: "0.01" }, inputSchema: { type: "object", properties: {} }, metrics: { status: "stable", run_time_ms: { p50: 3543, p95: 27748 }, sample_size: 75 } })); + const detail = await cli("inspect", ["sandbase_a"], env, inspect.fetch); + assert.match(detail.out, /Health: stable · 3\.5s typical, 27\.7s tail · 75 samples/); + // No metrics -> no HEALTH noise beyond the (empty) column. + const bare = fakeFetch(() => toolResult({ tool_name: "sandbase_b", description: "y", pricing: {}, inputSchema: { type: "object", properties: {} } })); + assert.doesNotMatch((await cli("inspect", ["sandbase_b"], env, bare.fetch)).out, /Health:/); +}); + 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 })); @@ -304,7 +317,7 @@ test("setup refreshes the bridge and SandBase-owned Skills but leaves edited Ski 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\.3\.0/); + assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.1/); 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; } } });