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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ 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.
Sync tools (LLMs, most data APIs) return their result directly; async tools (image, video, audio jobs) return a run id — wait with `-w`, or fetch later with `sandbase runs get -r <run_id>`. If `api.sandbase.ai` is unreachable, the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server.

### MCP Tools (available to your agent after connecting)

Expand Down
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.2",
"version": "0.3.3",
"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.2",
"version": "0.3.3",
"repository": {
"url": "https://github.com/sandbaseai/cli",
"source": "github"
Expand All @@ -26,7 +26,7 @@
{
"registryType": "npm",
"identifier": "@sandbaseai/cli",
"version": "0.3.2",
"version": "0.3.3",
"packageArguments": [
{
"type": "positional",
Expand Down
26 changes: 5 additions & 21 deletions skills/sandbase/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: sandbase
version: 0.3.2
version: 0.3.3
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 @@ -67,10 +67,10 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run`

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": "<prediction_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 <run_id> -o result.json`.
3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})`. There are two kinds of tools:
- **Sync** (LLMs, most data APIs): the call waits for the upstream and returns `status: "completed"` with `output` directly.
- **Async** (image, video, audio, other long jobs): the call returns at once with `status: "running"` and a `run_id` (also `prediction_id` / `taskId`).
4. For an async run, call `sandbase_run_get({"run_id": "<run_id>"})` every 5–10 seconds until `status` is `completed` or `failed`; the finished `output` is in that response. A run that has finished but is still copying its files also reports `running` — keep polling. Never call `sandbase_run` again for the same job (that starts and bills a new run).

**Agent Services** (`svc_…` names from `type: "service"`): pass `arguments: {"idempotency_key": "<unique>", "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.

Expand Down Expand Up @@ -158,22 +158,6 @@ 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.2",
"version": "0.3.3",
"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
4 changes: 3 additions & 1 deletion src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export function detectClient(client: Client): {installed:boolean; detail:string}
if (!profile.executable) return profile.mode === "auto" && existsSync(path) ? {installed:true,detail:"existing configuration"} : {installed:false,detail:"not detected"}; const result=spawnSync(profile.executable,["--version"],{encoding:"utf8",timeout:5000}); const detail=(result.stdout || result.stderr || "").trim().split("\n")[0] || "not found"; if (result.status===0) return {installed:true,detail}; if (client === "codex" && existsSync(path)) return {installed:true,detail:"Codex CLI probe failed; existing Codex configuration detected"}; return {installed:false,detail}; }
const start = "# >>> sandbase managed >>>", end = "# <<< sandbase managed <<<";
function block(client: Client, bridge: string): string {
if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\nenv = { SANDBASE_CLI_MANAGED = "1" }\n${end}`;
// tool_timeout_sec: Codex cancels tool calls after 60s by default; a sync model
// (e.g. a long LLM or scrape call) can legitimately take longer.
if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\ntool_timeout_sec = 1800\nenv = { SANDBASE_CLI_MANAGED = "1" }\n${end}`;
return ` ${start}\n sandbase:\n command: node\n args:\n - ${JSON.stringify(bridge)}\n - --client\n - ${client}\n ${end}`;
}
function managedText(raw: string, value: string): string { const re = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}`, "m"); return re.test(raw) ? raw.replace(re, value) : raw.replace(/\s*$/, "") + (raw.trim() ? "\n\n" : "") + value + "\n"; }
Expand Down
50 changes: 18 additions & 32 deletions src/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ class Output {
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); }
/** Always stderr (also in -j mode), so stdout stays parseable. */
progress(text: string): void { 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; }
Expand Down Expand Up @@ -79,29 +81,6 @@ 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;
function parse<T extends OptionSpec>(argv: string[], options: T) {
Expand Down Expand Up @@ -167,14 +146,19 @@ async function pollRun(client: SandBaseMCPClient, initial: RunView, timeoutSecon
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++;
// A completed media run reports no output while its files are transferred:
// keep waiting for those until the timeout; other empty completions (e.g. no
// stored output) settle after a few reads.
const transferring = phase === "succeeded" && !hasOutput(view) && /still being transferred/i.test(JSON.stringify(view.hints ?? ""));
if (phase === "failed" || (phase === "succeeded" && !transferring && (hasOutput(view) || emptyCompletions >= 3))) return view;
if (phase === "succeeded" && !transferring) 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 } : {}) };
// Hints describe the latest state only; a stale "still in progress" must not survive completion.
const { hints: _staleHints, ...rest } = view;
view = { ...rest, ...next, ...(tool ? { tool } : {}) };
}
}

Expand Down Expand Up @@ -226,8 +210,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), 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"));
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), truncate(String(item.description ?? ""), 60)]);
out.print([`Found ${items.length} tool${items.length === 1 ? "" : "s"}${q ? ` for "${q}"` : ""}:`, "", table(["TOOL", "TYPE", "VENDOR", "PRICE", "DESCRIPTION"], rows), "", "Next: sandbase inspect <TOOL> (input schema, exact price, and a ready-to-run command)"].join("\n"));
printHints(out, data.hints, data.warnings);
return 0;
}
Expand Down Expand Up @@ -278,8 +262,6 @@ 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 Expand Up @@ -314,7 +296,11 @@ async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Re
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);
if (values.wait && phaseOf(view.status) === "running") {
// stderr, also in -j mode: an agent whose shell times out still sees the run id to resume.
out.progress(`Run ${view.run_id} started; waiting for the result (up to ${timeout}s). If interrupted, resume with: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w${values.output ? ` -o ${values.output}` : ""}`);
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 */ }
}
Expand Down
8 changes: 8 additions & 0 deletions test/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ for (const client of ["codex","claude-code","cursor","hermes"] as Client[]) test
assert.equal(await unregister(client,env),true); const removed=await readFile(path,"utf8"); assert.ok(!removed.includes("safe bridge")); assert.ok(removed.includes("other") || removed.includes("model")); await rollback(one); assert.equal(await readFile(path,"utf8"),original);
});

test("Codex config raises the tool timeout so long sync calls are not cut at 60s", async () => {
const home=await mkdtemp(join(tmpdir(),"sandbase-codex-timeout-")); const env={HOME:home,CODEX_HOME:join(home,".codex")}; await mkdir(env.CODEX_HOME,{recursive:true});
await configure("codex","/sandbase-mcp-bridge.mjs",env);
const text=await readFile(join(env.CODEX_HOME,"config.toml"),"utf8");
assert.match(text,/^tool_timeout_sec = 1800$/m);
assert.equal(await isConfigured("codex",env),true);
});

test("Codex and Hermes leave a pre-existing unowned sandbase entry untouched",async()=>{ for(const client of ["codex","hermes"] as Client[]){ const home=await mkdtemp(join(tmpdir(),"sandbase-existing-")); const env={HOME:home,CODEX_HOME:join(home,".codex"),HERMES_HOME:join(home,".hermes")}; const path=client==="codex"?join(env.CODEX_HOME,"config.toml"):join(env.HERMES_HOME,"config.yaml"); const original=client==="codex"?'[mcp_servers.sandbase]\ncommand="old"\n[mcp_servers.other]\ncommand="keep"\n':'mcp_servers:\n sandbase:\n command: old\n other:\n command: keep\n'; await mkdir(join(path,".."),{recursive:true}); await writeFile(path,original); await assert.rejects(configure(client,"/new",env),/invalid/); assert.equal(await readFile(path,"utf8"),original); }});

test("Claude Code and Cursor leave a pre-existing unowned sandbase key untouched",async()=>{ for(const client of ["claude-code","cursor"] as Client[]){ const home=await mkdtemp(join(tmpdir(),"sandbase-json-existing-")); const path=client==="claude-code"?join(home,".claude.json"):join(home,".cursor","mcp.json"); const original=JSON.stringify({theme:"keep",mcpServers:{sandbase:{command:"old"},other:{command:"keep"}}},null,2); await mkdir(join(path,".."),{recursive:true}); await writeFile(path,original); await assert.rejects(configure(client,"/new bridge",{HOME:home}),/invalid/); assert.equal(await readFile(path,"utf8"),original); }});
Expand Down
Loading
Loading