@sandbaseai/cli
Advanced tools
@@ -5,2 +5,3 @@ import { mkdir, readFile } from "node:fs/promises"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { existsSync } from "node:fs"; | ||
| import { parse as parseToml } from "smol-toml"; | ||
@@ -12,4 +13,5 @@ import { parseDocument } from "yaml"; | ||
| const defaultIO = { backup, write: atomicWrite, restore }; | ||
| export function detectClient(client) { const profile = clientProfiles[client]; if (!profile.executable || profile.mode !== "auto") | ||
| return { installed: true, detail: "manual setup" }; const result = spawnSync(profile.executable, ["--version"], { encoding: "utf8", timeout: 5000 }); const detail = (result.stdout || result.stderr || "").trim().split("\n")[0] || "not found"; return { installed: result.status === 0, detail }; } | ||
| export function detectClient(client) { const profile = clientProfiles[client]; if (profile.mode !== "auto") | ||
| return { installed: true, detail: "manual setup" }; if (!profile.executable) | ||
| return existsSync(configPath(client)) ? { installed: true, detail: "existing configuration" } : { installed: false, detail: "configuration not found" }; const result = spawnSync(profile.executable, ["--version"], { encoding: "utf8", timeout: 5000 }); const detail = (result.stdout || result.stderr || "").trim().split("\n")[0] || "not found"; return { installed: result.status === 0, detail }; } | ||
| const start = "# >>> sandbase managed >>>", end = "# <<< sandbase managed <<<"; | ||
@@ -16,0 +18,0 @@ function block(client, bridge) { |
@@ -1,2 +0,2 @@ | ||
| import type { Client, Exchange, Grant } from "../types.js"; | ||
| import type { ConnectClient, Exchange, Grant } from "../types.js"; | ||
| export interface Secrets { | ||
@@ -20,3 +20,3 @@ requestId: string; | ||
| private request; | ||
| create(client: Client, s: Secrets): Promise<Grant>; | ||
| create(client: ConnectClient, s: Secrets): Promise<Grant>; | ||
| status(id: string, secret: string): Promise<{ | ||
@@ -23,0 +23,0 @@ status: string; |
@@ -1,2 +0,2 @@ | ||
| import type { Client, Exchange } from "../types.js"; | ||
| import type { ConnectClient, Exchange } from "../types.js"; | ||
| import { AuthorizationApi } from "./api.js"; | ||
@@ -9,5 +9,5 @@ export interface FlowOptions { | ||
| } | ||
| export declare function authorize(api: AuthorizationApi, client: Client, options: FlowOptions): Promise<{ | ||
| export declare function authorize(api: AuthorizationApi, client: ConnectClient, options: FlowOptions): Promise<{ | ||
| authorizationId: string; | ||
| exchange: Exchange; | ||
| }>; |
+9
-16
| #!/usr/bin/env node | ||
| import { connect, doctor, unregister } from "./commands.js"; | ||
| import { detectClient } from "./adapters/index.js"; | ||
| import { clientList, clientProfiles } from "./clients.js"; | ||
| import { clients, isClient } from "./types.js"; | ||
| import { clientList } from "./clients.js"; | ||
| import { isClient } from "./types.js"; | ||
| if (Number(process.versions.node.split(".")[0]) < 20) { | ||
@@ -13,11 +12,5 @@ console.error("SandBase CLI requires Node.js 20 or newer. Upgrade Node.js and retry."); | ||
| const value = at >= 0 ? args[at + 1] : undefined; | ||
| function autoClient() { | ||
| return clients.find(client => { | ||
| const profile = clientProfiles[client]; | ||
| return profile.mode === "auto" && !!profile.executable && detectClient(client).installed; | ||
| }); | ||
| } | ||
| const resolved = value === "auto" || (!value && command === "connect") ? autoClient() : value && isClient(value) ? value : undefined; | ||
| if (!command || !resolved || (command !== "connect" && !value) || !["connect", "doctor", "unregister"].includes(command)) { | ||
| console.error(`Usage: sandbase <connect|doctor|unregister> --client <${clientList()}|auto>`); | ||
| const client = value === undefined || value === "auto" ? "auto" : isClient(value) ? value : undefined; | ||
| if (!command || !client || !["connect", "doctor", "unregister"].includes(command) || (at >= 0 && !value)) { | ||
| console.error(`Usage: sandbase <connect|doctor|unregister> [--client <${clientList()}|auto>]`); | ||
| process.exit(2); | ||
@@ -30,9 +23,9 @@ } | ||
| if (command === "connect") | ||
| await connect(resolved, { signal: controller.signal }); | ||
| await connect(client, { signal: controller.signal }); | ||
| else if (command === "doctor") { | ||
| if (!(await doctor(resolved))) | ||
| if (!(await doctor(client))) | ||
| process.exitCode = 1; | ||
| } | ||
| else | ||
| await unregister(resolved); | ||
| await unregister(client); | ||
| } | ||
@@ -44,4 +37,4 @@ catch (e) { | ||
| else | ||
| console.error(message.replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]")); | ||
| console.error(message.replace(/(?:sk|cln)-[A-Za-z0-9_-]+/g, "[REDACTED]")); | ||
| process.exitCode = 1; | ||
| } |
@@ -12,1 +12,2 @@ import type { Client } from "./types.js"; | ||
| export declare function clientList(): string; | ||
| export declare function autoClients(): Client[]; |
+3
-0
@@ -30,1 +30,4 @@ export const clientProfiles = { | ||
| } | ||
| export function autoClients() { | ||
| return Object.values(clientProfiles).filter(profile => profile.mode === "auto" && !!profile.adapter).map(profile => profile.id); | ||
| } |
+15
-12
@@ -1,17 +0,20 @@ | ||
| import type { Client } from "./types.js"; | ||
| import type { Client, ConnectClient } from "./types.js"; | ||
| import { AuthorizationApi } from "./auth/api.js"; | ||
| import { FileCredentialStore } from "./credentials/store.js"; | ||
| export declare function openBrowser(url: string): Promise<void>; | ||
| export declare function connect(client: Client, deps?: { | ||
| import { type CredentialStore } from "./credentials/store.js"; | ||
| type Detection = { | ||
| installed: boolean; | ||
| detail: string; | ||
| }; | ||
| export interface CommandDependencies { | ||
| api?: AuthorizationApi; | ||
| store?: FileCredentialStore; | ||
| store?: CredentialStore; | ||
| open?: (url: string) => Promise<void>; | ||
| signal?: AbortSignal; | ||
| log?: (s: string) => void; | ||
| detect?: (client: Client) => { | ||
| installed: boolean; | ||
| detail: string; | ||
| }; | ||
| }): Promise<void>; | ||
| export declare function doctor(client: Client, store?: FileCredentialStore): Promise<boolean>; | ||
| export declare function unregister(client: Client, store?: FileCredentialStore): Promise<void>; | ||
| detect?: (client: Client) => Detection; | ||
| } | ||
| export declare function openBrowser(url: string): Promise<void>; | ||
| export declare function connect(client?: ConnectClient, deps?: CommandDependencies): Promise<void>; | ||
| export declare function doctor(client?: ConnectClient, store?: CredentialStore, detect?: (client: Client) => Detection): Promise<boolean>; | ||
| export declare function unregister(client?: ConnectClient, store?: CredentialStore, detect?: (client: Client) => Detection): Promise<void>; | ||
| export {}; |
+99
-21
@@ -5,3 +5,3 @@ import { spawn } from "node:child_process"; | ||
| import { authorize } from "./auth/flow.js"; | ||
| import { clientProfiles } from "./clients.js"; | ||
| import { autoClients, clientProfiles } from "./clients.js"; | ||
| import { FileCredentialStore } from "./credentials/store.js"; | ||
@@ -20,26 +20,29 @@ import { configure, detectClient, installBridge, isConfigured, rollback, rollbackBridge, unregister as removeAdapter } from "./adapters/index.js"; | ||
| export async function openBrowser(url) { const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open"; const args = platform() === "win32" ? ["/c", "start", "", url] : [url]; const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); child.unref(); } | ||
| function mcpServerSnippet(client, bridgePath) { | ||
| return JSON.stringify({ mcpServers: { sandbase: { command: "node", args: [bridgePath, "--client", client] } } }, null, 2); | ||
| } | ||
| function nextStepPrompts(label) { | ||
| return [`2. In ${label}, try asking:`, ` - List the available SandBase MCP tools.`, ` - Use SandBase to fetch Elon Musk's latest 10 posts on Twitter.`]; | ||
| } | ||
| function mcpServerSnippet(client, bridgePath) { return JSON.stringify({ mcpServers: { sandbase: { command: "node", args: [bridgePath, "--client", client] } } }, null, 2); } | ||
| function nextStepPrompts(label) { return [`2. In ${label}, try asking:`, " - List the available SandBase MCP tools.", " - Use SandBase to fetch Elon Musk's latest 10 posts on Twitter."]; } | ||
| function successMessage(client, record, bridgePath, configuredPath) { | ||
| const profile = clientProfiles[client]; | ||
| const label = profile.label; | ||
| const common = [``, `SandBase MCP is connected.`, ``, `Client: ${label} (${client})`, `MCP URL: ${record.mcpUrl}`, `Credential: ${record.keyPrefix}... (${record.scope.join(",")})`, `Bridge: ${bridgePath}`]; | ||
| const common = ["", "SandBase MCP is connected.", "", `Client: ${label} (${client})`, `MCP URL: ${record.mcpUrl}`, `Credential: ${record.keyPrefix}... (${record.scope.join(",")})`, `Bridge: ${bridgePath}`]; | ||
| if (profile.mode === "auto") | ||
| return [...common, `Config: ${configuredPath || "updated"}`, ``, `Next steps:`, `1. Restart or reload ${label} so it picks up the new MCP configuration.`, ...nextStepPrompts(label), ``, `Manage access: revoke the "CLI Login" key in SandBase Dashboard when you no longer need it.`].join("\n"); | ||
| return [...common, `Config: ${configuredPath || "updated"}`, "", "Next steps:", `1. Restart or reload ${label} so it picks up the new MCP configuration.`, ...nextStepPrompts(label), "", 'Manage access: revoke the "CLI Login" key in SandBase Dashboard when you no longer need it.'].join("\n"); | ||
| if (profile.mode === "skill") | ||
| return [...common, ``, `Skill/prompt setup required.`, `Copy the following instruction into ${label}:`, ``, `Install the SandBase MCP bridge for ${label}. Use this local MCP server configuration and keep the scope limited to SandBase:`, mcpServerSnippet(client, bridgePath), `After setup, reload ${label} if needed.`, ``, `Next steps:`, `1. Finish the skill/prompt setup in ${label}.`, ...nextStepPrompts(label), ``, `Manage access: revoke the "CLI Login" key in SandBase Dashboard when you no longer need it.`].join("\n"); | ||
| return [...common, ``, `Manual MCP configuration required.`, `Add this MCP server configuration to ${label}:`, ``, mcpServerSnippet(client, bridgePath), ``, `Next steps:`, `1. Save the configuration and restart or reload ${label}.`, ...nextStepPrompts(label), ``, `Manage access: revoke the "CLI Login" key in SandBase Dashboard when you no longer need it.`].join("\n"); | ||
| return [...common, "", "Skill/prompt setup required.", `Copy the following instruction into ${label}:`, "", `Install the SandBase MCP bridge for ${label}. Use this local MCP server configuration and keep the scope limited to SandBase:`, mcpServerSnippet(client, bridgePath), `After setup, reload ${label} if needed.`, "", "Next steps:", "1. Finish the skill/prompt setup in ${label}.", ...nextStepPrompts(label), "", 'Manage access: revoke the "CLI Login" key in SandBase Dashboard when you no longer need it.'].join("\n"); | ||
| return [...common, "", "Manual MCP configuration required.", `Add this MCP server configuration to ${label}:`, "", mcpServerSnippet(client, bridgePath), "", "Next steps:", `1. Save the configuration and restart or reload ${label}.`, ...nextStepPrompts(label), "", 'Manage access: revoke the "CLI Login" key in SandBase Dashboard when you no longer need it.'].join("\n"); | ||
| } | ||
| export async function connect(client, deps = {}) { | ||
| function plannedAutoClients(detect) { return autoClients().filter(client => detect(client).installed); } | ||
| function recordFor(client, exchange) { return { credential: exchange.credential, credentialId: exchange.credential_id, keyPrefix: exchange.key_prefix, client, scope: exchange.scope, mcpUrl: exchange.mcp_url, createdAt: exchange.created_at }; } | ||
| async function restoreCredential(store, client, previous) { if (previous) | ||
| await store.save(previous); | ||
| else | ||
| await store.remove(client); } | ||
| async function connectOne(client, deps) { | ||
| const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); | ||
| const store = deps.store || new FileCredentialStore(); | ||
| const log = deps.log || console.log; | ||
| const detect = deps.detect || detectClient; | ||
| const profile = clientProfiles[client]; | ||
| const detected = (deps.detect || detectClient)(client); | ||
| const detected = detect(client); | ||
| if (!detected.installed) | ||
| throw new Error(`${profile.label} is not installed or is incompatible (${detected.detail}). Install a supported version, then retry.`); | ||
| throw new Error(`${profile.label} is not installed or is incompatible. Install a supported version, then retry.`); | ||
| const previous = await store.get(client); | ||
@@ -50,3 +53,3 @@ const { authorizationId, exchange } = await authorize(api, client, { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); | ||
| try { | ||
| const record = { credential: exchange.credential, credentialId: exchange.credential_id, keyPrefix: exchange.key_prefix, client, scope: exchange.scope, mcpUrl: exchange.mcp_url, createdAt: exchange.created_at }; | ||
| const record = recordFor(client, exchange); | ||
| await store.save(record); | ||
@@ -65,6 +68,3 @@ bridgeResult = await installBridge(); | ||
| await rollbackBridge(bridgeResult).catch(() => undefined); | ||
| if (previous) | ||
| await store.save(previous).catch(() => undefined); | ||
| else | ||
| await store.remove(client).catch(() => undefined); | ||
| await restoreCredential(store, client, previous).catch(() => undefined); | ||
| if (!(await compensate(api, authorizationId, exchange.cleanup_token))) | ||
@@ -76,3 +76,81 @@ console.error("WARNING: automatic credential cleanup failed. Revoke the new CLI credential in SandBase Dashboard immediately."); | ||
| } | ||
| export async function doctor(client, store = new FileCredentialStore()) { const credential = await store.get(client); const profile = clientProfiles[client]; const configured = profile.mode === "auto" ? await isConfigured(client) : !!credential; const detected = detectClient(client); console.log(`${client}: mode=${profile.mode}, installed=${detected.installed ? "yes" : "no"}, config=${configured ? profile.mode === "auto" ? "ok" : "manual" : "missing"}, credential=${credential ? credential.keyPrefix + "…" : "missing"}, url=${credential?.mcpUrl || "unknown"}, scope=${credential?.scope.join(",") || "unknown"}`); return detected.installed && !!credential && configured; } | ||
| export async function unregister(client, store = new FileCredentialStore()) { const removed = await removeAdapter(client); await store.remove(client); console.log(`Removed local SandBase registration for ${client}.${removed ? "" : " No managed client configuration was changed."} Revoke the server credential in SandBase Dashboard if it is no longer needed.`); } | ||
| async function connectAuto(deps) { | ||
| const api = deps.api || new AuthorizationApi((process.env.SANDBASE_API_URL || "https://sandbase.ai").replace(/\/$/, "")); | ||
| const store = deps.store || new FileCredentialStore(); | ||
| const log = deps.log || console.log; | ||
| const detect = deps.detect || detectClient; | ||
| const targets = plannedAutoClients(detect); | ||
| if (!targets.length) { | ||
| log("No installed compatible clients support automatic configuration. Use --client <client> for manual or skill setup guidance."); | ||
| return; | ||
| } | ||
| const { authorizationId, exchange } = await authorize(api, "auto", { open: deps.open || openBrowser, sleep, log, ...(deps.signal ? { signal: deps.signal } : {}) }); | ||
| let bridgeResult; | ||
| const succeeded = [], failed = []; | ||
| try { | ||
| bridgeResult = await installBridge(); | ||
| for (const client of targets) { | ||
| const previous = await store.get(client); | ||
| let configured; | ||
| try { | ||
| const record = recordFor(client, exchange); | ||
| await store.save(record); | ||
| configured = await configure(client, bridgeResult.path); | ||
| if (!(await isConfigured(client))) | ||
| throw new Error("Client configuration verification failed"); | ||
| succeeded.push(client); | ||
| } | ||
| catch { | ||
| if (configured) | ||
| await rollback(configured).catch(() => undefined); | ||
| await restoreCredential(store, client, previous).catch(() => undefined); | ||
| failed.push(client); | ||
| } | ||
| } | ||
| if (!succeeded.length) | ||
| throw new Error("No clients were configured successfully."); | ||
| log(`Configured clients: ${succeeded.join(", ")}.`); | ||
| if (failed.length) | ||
| log(`Partial success: failed clients were rolled back: ${failed.join(", ")}.`); | ||
| exchange.cleanup_token = ""; | ||
| } | ||
| catch (e) { | ||
| if (bridgeResult) | ||
| await rollbackBridge(bridgeResult).catch(() => undefined); | ||
| if (!(await compensate(api, authorizationId, exchange.cleanup_token))) | ||
| console.error("WARNING: automatic credential cleanup failed. Revoke the new CLI credential in SandBase Dashboard immediately."); | ||
| exchange.cleanup_token = ""; | ||
| throw e; | ||
| } | ||
| } | ||
| export async function connect(client = "auto", deps = {}) { if (client === "auto") | ||
| return connectAuto(deps); return connectOne(client, deps); } | ||
| export async function doctor(client = "auto", store = new FileCredentialStore(), detect = detectClient) { | ||
| const targets = client === "auto" ? plannedAutoClients(detect) : [client]; | ||
| if (!targets.length) { | ||
| console.log("No installed compatible clients support automatic configuration."); | ||
| return false; | ||
| } | ||
| let healthy = true; | ||
| for (const target of targets) { | ||
| const credential = await store.get(target); | ||
| const profile = clientProfiles[target]; | ||
| const configured = profile.mode === "auto" ? await isConfigured(target) : !!credential; | ||
| const detected = detect(target); | ||
| console.log(`${target}: mode=${profile.mode}, installed=${detected.installed ? "yes" : "no"}, config=${configured ? profile.mode === "auto" ? "ok" : "manual" : "missing"}, credential=${credential ? credential.keyPrefix + "…" : "missing"}, url=${credential?.mcpUrl || "unknown"}, scope=${credential?.scope.join(",") || "unknown"}`); | ||
| healthy = healthy && detected.installed && !!credential && configured; | ||
| } | ||
| return healthy; | ||
| } | ||
| export async function unregister(client = "auto", store = new FileCredentialStore(), detect = detectClient) { | ||
| const targets = client === "auto" ? plannedAutoClients(detect) : [client]; | ||
| if (!targets.length) { | ||
| console.log("No installed compatible clients support automatic configuration."); | ||
| return; | ||
| } | ||
| for (const target of targets) { | ||
| const removed = await removeAdapter(target); | ||
| await store.remove(target); | ||
| console.log(`Removed local SandBase registration for ${target}.${removed ? "" : " No managed client configuration was changed."} Revoke the server credential in SandBase Dashboard if it is no longer needed.`); | ||
| } | ||
| } |
+2
-1
| export declare const clients: readonly ["codex", "claude-code", "cursor", "windsurf", "gemini-cli", "opencode", "chatgpt", "hermes", "openclaw", "antigravity", "claude-desktop", "cursor-cli", "warp", "trae", "kimi-cli", "qwen-code", "kiro-cli", "amp", "crush", "iflow-cli", "qoder", "workbuddy", "cowork", "pi"]; | ||
| export type Client = typeof clients[number]; | ||
| export type ConnectClient = Client | "auto"; | ||
| export declare function isClient(value: string): value is Client; | ||
@@ -24,3 +25,3 @@ export interface CredentialRecord { | ||
| key_prefix: string; | ||
| client: Client; | ||
| client: ConnectClient; | ||
| scope: string[]; | ||
@@ -27,0 +28,0 @@ mcp_url: string; |
+1
-1
| { | ||
| "name": "@sandbaseai/cli", | ||
| "version": "0.1.2", | ||
| "version": "0.1.3", | ||
| "description": "Secure SandBase MCP onboarding CLI", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
38958
10.75%655
13.91%13
18.18%