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.5",
"version": "0.3.6",
"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.5",
"version": "0.3.6",
"repository": {
"url": "https://github.com/sandbaseai/cli",
"source": "github"
Expand All @@ -26,7 +26,7 @@
{
"registryType": "npm",
"identifier": "@sandbaseai/cli",
"version": "0.3.5",
"version": "0.3.6",
"packageArguments": [
{
"type": "positional",
Expand Down
6 changes: 4 additions & 2 deletions skills/sandbase/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: sandbase
version: 0.3.5
version: 0.3.6
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 @@ -129,7 +129,7 @@ Always move both to the latest; never downgrade one to match the other. If they

- 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.
- `-o` writes only the tool's output (no envelope). In text mode, `Files:` lists the files the run produced; URLs inside data results are in the output itself.
- 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 <run_id> -w` — never re-run (that bills again).
- To store an API key without exposing it: `printf %s "$KEY" | sandbase keys add -k -`.
Expand Down Expand Up @@ -203,7 +203,9 @@ Do not use SandBase for purely local work (editing files, reasoning over code al
- 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.
- If no tool matches every word, discover returns the closest matches with a hint saying so; check their descriptions before using one.
- Zero results come with `hints`; read them before rephrasing.
- `inspect` hints name the inputs that take a file URL; for a local file use `sandbase upload` (§3).

---

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.5",
"version": "0.3.6",
"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
39 changes: 31 additions & 8 deletions src/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,35 @@ function hintLines(hints: unknown): string[] {
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<string>()): Set<string> {
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;
/**
* Files the run produced. The server returns media outputs as `[{url, …}]`
* (content_type is only added when it can be inferred), so an entry counts as a
* file when it has a `url` and carries no content of its own. A scraped page's
* `{url, markdown}`, a tweet row, or a nested avatar is content, not a file.
*/
export function generatedFiles(output: unknown): string[] {
const isUrl = (value: unknown): value is string => typeof value === "string" && /^https?:\/\/\S+$/.test(value);
// Keys that make an object a data row rather than a produced file.
const contentKeys = ["text", "markdown", "md", "html", "content", "title", "body", "snippet", "caption", "summary", "data", "items", "results", "children", "comments", "author", "user"];
const fileEntry = (entry: unknown): string | undefined => {
if (isUrl(entry)) return entry;
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined;
const record = entry as Record<string, unknown>;
if (!isUrl(record.url)) return undefined;
return contentKeys.some(key => record[key] !== undefined) ? undefined : record.url as string;
};
const list = (value: unknown): string[] => {
const entries = Array.isArray(value) ? value : [value];
return entries.map(fileEntry).filter((url): url is string => !!url);
};
let files = list(output);
// {images: [{url}], seed: 1} / {video: {url}}: a media-named key holds the files.
if (!files.length && output && typeof output === "object" && !Array.isArray(output)) {
for (const [key, value] of Object.entries(output)) {
if (/^(image|video|audio|file|media|clip|frame)s?$/i.test(key)) files = files.concat(list(value));
}
}
return [...new Set(files)].slice(0, 20);
}
function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
type OptionSpec = Record<string, { type: "string" | "boolean"; short?: string }>;
Expand Down Expand Up @@ -171,8 +194,8 @@ function printRun(out: Output, view: RunView, outputFile?: string): void {
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}`));
const files = generatedFiles(view.output);
if (files.length) lines.push("Files:", ...files.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 ?? "<run_id>"} -o result.json\` to save everything)` : text); }
}
Expand Down
24 changes: 22 additions & 2 deletions test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { PassThrough } from "node:stream";
import { pathToFileURL } from "node:url";
import test from "node:test";
import { httpError, resolveEndpoint, SandBaseError, SandBaseMCPClient, type FetchLike } from "../src/agent/client.js";
import { formatPrice, resolveSchema, runAgentCommand, uploadUrlFor } from "../src/agent/commands.js";
import { formatPrice, generatedFiles, resolveSchema, runAgentCommand, uploadUrlFor } from "../src/agent/commands.js";
import { probeEndpoint } from "../src/agent/probe.js";
import { compareVersions, updateNotice } from "../src/agent/update.js";

Expand Down Expand Up @@ -331,7 +331,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\.5/);
assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.6/);
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; } }
});
Expand Down Expand Up @@ -380,3 +380,23 @@ test("upload follows SANDBASE_MCP_URL, maps 402/404, and rejects a response with
const empty = await cli("upload", [png, "-j"], env, reply(200, "{}"));
assert.equal(JSON.parse(empty.out).error.code, "INVALID_RESPONSE");
});
test("Files lists what a run produced, not every URL inside data results", () => {
assert.deepEqual(generatedFiles([{ url: "https://cdn/x.mp4", content_type: "video/mp4" }]), ["https://cdn/x.mp4"]);
// Media entries carry any number of extra fields; a marker is what identifies a file.
assert.deepEqual(generatedFiles([{ url: "https://cdn/1.png", content_type: "image/png", file_name: "1.png", file_size: 12, width: 1024, height: 1024, seed: 7, nsfw: false }]), ["https://cdn/1.png"]);
assert.deepEqual(generatedFiles([{ url: "https://example.com/page", markdown: "# hi", html: "<h1>hi</h1>" }]), []);
// content_type is only present when the server could infer it from the URL.
assert.deepEqual(generatedFiles([{ url: "https://cdn/abc123?token=x" }]), ["https://cdn/abc123?token=x"]);
// One caption entry must not hide the file, and a long run is truncated, not dropped.
assert.deepEqual(generatedFiles([{ url: "https://cdn/a.mp4", content_type: "video/mp4" }, { text: "caption" }]), ["https://cdn/a.mp4"]);
assert.equal(generatedFiles(Array.from({ length: 25 }, (_, i) => ({ url: `https://cdn/${i}.png`, content_type: "image/png" }))).length, 20);
assert.deepEqual(generatedFiles({ urls: ["https://example.com/a", "https://example.com/b"] }), []);
assert.deepEqual(generatedFiles({ output: { url: "https://example.com/page", markdown: "x" } }), []);
assert.deepEqual(generatedFiles("https://cdn/a.png"), ["https://cdn/a.png"]);
assert.deepEqual(generatedFiles({ images: [{ url: "https://cdn/1.png" }, { url: "https://cdn/2.png" }], seed: 3 }), ["https://cdn/1.png", "https://cdn/2.png"]);
assert.deepEqual(generatedFiles({ video: { url: "https://cdn/v.mp4" } }), ["https://cdn/v.mp4"]);
assert.deepEqual(generatedFiles({ result: { url: "https://example.com/page", markdown: "x" } }), []);
const tweets = [{ id: "1", text: "hi", user: { avatar: "https://pbs/a.jpg" }, media: [{ url: "https://video/1.mp4" }] }];
assert.deepEqual(generatedFiles(tweets), []);
assert.deepEqual(generatedFiles({ text: "see https://x.com" }), []);
});
Loading