Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,7 +26,7 @@
{
"registryType": "npm",
"identifier": "@sandbaseai/cli",
"version": "0.3.0",
"version": "0.3.1",
"packageArguments": [
{
"type": "positional",
Expand Down
18 changes: 17 additions & 1 deletion skills/sandbase/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion skills/sandbase/skill.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
28 changes: 26 additions & 2 deletions src/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ function collectUrls(value: unknown, found = new Set<string>()): Set<string> {
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<string, { type: "string" | "boolean"; short?: string }>;
const commonOptions = { json: { type: "boolean", short: "j" }, client: { type: "string" }, help: { type: "boolean", short: "h" } } as const;
Expand Down Expand Up @@ -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<Record<string, unknown>>;
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 <TOOL> (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 <TOOL> (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;
}
Expand Down Expand Up @@ -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 })}`);
Expand Down
15 changes: 14 additions & 1 deletion test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down Expand Up @@ -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; } }
});
Loading