@sandbaseai/cli
Advanced tools
@@ -12,8 +12,11 @@ import { mkdir, readFile } from "node:fs/promises"; | ||
| const defaultIO = { backup, write: atomicWrite, restore }; | ||
| export function detectClient(client) { const profile = clientProfiles[client]; if (!profile.executable) | ||
| return profile.mode === "auto" && existsSync(configPath(client)) ? { 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"; return { installed: result.status === 0, detail }; } | ||
| const ownershipEnvironment = "SANDBASE_CLI_MANAGED"; | ||
| export function detectClient(client) { const profile = clientProfiles[client]; const path = configPath(client); 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, bridge) { | ||
| if (client === "codex") | ||
| return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}", "--client", "codex"]\n${end}`; | ||
| return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}", "--client", "codex"]\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}`; | ||
@@ -35,8 +38,15 @@ } | ||
| return cleaned.replace(/^mcp_servers:[ \t]*$/m, match => `${match}\n${own}`); return cleaned.replace(/\s*$/, "") + (cleaned.trim() ? "\n" : "") + `mcp_servers:\n${own}\n`; } | ||
| function ownedJsonEntry(value, client) { if (!value || typeof value !== "object" || Array.isArray(value)) | ||
| return false; const entry = value; return entry.command === "node" && Array.isArray(entry.args) && (client === undefined || entry.args.includes(client)) && entry.env?.[ownershipEnvironment] === "1"; } | ||
| function ownedCodexEntry(value) { if (!value || typeof value !== "object" || Array.isArray(value)) | ||
| return false; const entry = value; return entry.command === "node" && Array.isArray(entry.args) && entry.args.length === 3 && typeof entry.args[0] === "string" && entry.args[0].endsWith("sandbase-mcp-bridge.mjs") && entry.args[1] === "--client" && entry.args[2] === "codex" && entry.env?.[ownershipEnvironment] === "1"; } | ||
| function jsonUpdate(raw, client, bridge, remove = false) { const obj = raw ? JSON.parse(raw) : {}; if (!obj || Array.isArray(obj)) | ||
| throw new Error("Client configuration root must be an object"); const servers = obj.mcpServers === undefined ? {} : obj.mcpServers; if (!servers || typeof servers !== "object" || Array.isArray(servers)) | ||
| throw new Error("mcpServers must be an object"); const map = servers; if (remove) | ||
| delete map.sandbase; | ||
| throw new Error("mcpServers must be an object"); const map = servers; const existing = map.sandbase; if (existing !== undefined && !ownedJsonEntry(existing, client)) | ||
| throw new Error("A non-SandBase MCP entry named sandbase was left untouched"); if (remove) { | ||
| if (existing !== undefined) | ||
| delete map.sandbase; | ||
| } | ||
| else | ||
| map.sandbase = { command: "node", args: [bridge, "--client", client] }; obj.mcpServers = map; return JSON.stringify(obj, null, 2) + "\n"; } | ||
| map.sandbase = { command: "node", args: [bridge, "--client", client], env: { [ownershipEnvironment]: "1" } }; obj.mcpServers = map; return JSON.stringify(obj, null, 2) + "\n"; } | ||
| function validateText(client, text) { if (!text.trim()) | ||
@@ -69,3 +79,10 @@ return; const adapter = clientProfiles[client].adapter; if (adapter === "codex") { | ||
| validateText(client, text); | ||
| next = profile.adapter === "json" ? jsonUpdate(raw, client, bridge) : profile.adapter === "yaml" ? hermesUpdate(text, bridge, client) : managedText(text.includes(start) ? text : removeCodexEntry(text), block(client, bridge)); | ||
| if (profile.adapter === "codex" && /\[mcp_servers\.sandbase\]/.test(text)) { | ||
| const parsed = parseToml(text); | ||
| if (!text.includes(start) || !ownedCodexEntry(parsed.mcp_servers?.sandbase)) | ||
| throw new Error("A non-SandBase MCP entry named sandbase was left untouched"); | ||
| } | ||
| if (profile.adapter === "yaml" && /^ sandbase:[ \t]*$/m.test(text) && !text.includes(start)) | ||
| throw new Error("A non-SandBase MCP entry named sandbase was left untouched"); | ||
| next = profile.adapter === "json" ? jsonUpdate(raw, client, bridge) : profile.adapter === "yaml" ? hermesUpdate(text, bridge, client) : managedText(text.includes(start) ? text : text, block(client, bridge)); | ||
| validateText(client, next); | ||
@@ -89,2 +106,9 @@ } | ||
| validateText(client, raw); | ||
| if (profile.adapter !== "json" && !raw.includes(start)) | ||
| return false; | ||
| if (profile.adapter === "codex") { | ||
| const parsed = parseToml(raw); | ||
| if (!ownedCodexEntry(parsed.mcp_servers?.sandbase)) | ||
| return false; | ||
| } | ||
| next = profile.adapter === "json" ? jsonUpdate(raw, client, "", true) : profile.adapter === "codex" ? removeCodexEntry(removeManaged(raw)) : removeHermesEntry(removeManaged(raw)); | ||
@@ -108,3 +132,3 @@ validateText(client, next); | ||
| const o = JSON.parse(raw); | ||
| return !!o.mcpServers?.sandbase; | ||
| return ownedJsonEntry(o.mcpServers?.sandbase, client); | ||
| } | ||
@@ -114,2 +138,13 @@ catch { | ||
| } | ||
| } return raw.includes(start) && raw.includes(end); } | ||
| } if (!raw.includes(start) || !raw.includes(end)) | ||
| return false; try { | ||
| if (profile.adapter === "codex") { | ||
| const parsed = parseToml(raw); | ||
| return ownedCodexEntry(parsed.mcp_servers?.sandbase); | ||
| } | ||
| const parsed = parseDocument(raw).toJS(); | ||
| return !!parsed?.mcp_servers?.sandbase; | ||
| } | ||
| catch { | ||
| return false; | ||
| } } |
+25
-0
@@ -18,2 +18,26 @@ import type { Client } from "./types.js"; | ||
| } | ||
| export type RuntimeStatus = "configured" | "already_configured" | "confirmation_required" | "unsupported" | "failed"; | ||
| export type ImplementationState = "implemented" | "blocked"; | ||
| export interface Evidence { | ||
| source: string; | ||
| retrieved: string; | ||
| conclusion: string; | ||
| kind: "first_party" | "internal_blocker"; | ||
| } | ||
| export interface InstallerDescriptor { | ||
| mcp: "adapter" | "confirmation" | "none"; | ||
| skill: "shared" | "private" | "confirmation" | "none"; | ||
| } | ||
| export interface ValidatorDescriptor { | ||
| mcp: "read_back" | "confirmation" | "none"; | ||
| skill: "ownership_checksum" | "real_client_matrix" | "confirmation" | "none"; | ||
| } | ||
| export interface CapabilityV2 { | ||
| evidence: Evidence; | ||
| installer: InstallerDescriptor; | ||
| validator: ValidatorDescriptor; | ||
| implementation: ImplementationState; | ||
| status: RuntimeStatus; | ||
| nextStep: string; | ||
| } | ||
| export interface ClientProfile { | ||
@@ -29,4 +53,5 @@ id: Client; | ||
| export declare const nativeCapabilities: Record<Client, NativeCapability>; | ||
| export declare const capabilityRegistry: Record<Client, CapabilityV2>; | ||
| export declare function assertNativeCapabilities(): void; | ||
| export declare function clientList(): string; | ||
| export declare function autoClients(): Client[]; |
+38
-4
@@ -28,3 +28,3 @@ export const clientProfiles = { | ||
| export const skillTiers = { | ||
| codex: "s3_promotion", "claude-code": "s3_fallback", cursor: "s3_promotion", hermes: "s3_fallback", | ||
| codex: "s3_promotion", "claude-code": "s1_slash", cursor: "s3_promotion", hermes: "s3_fallback", | ||
| windsurf: "s4_none", "gemini-cli": "s4_none", opencode: "s4_none", chatgpt: "s4_none", openclaw: "s4_none", antigravity: "s4_none", "claude-desktop": "s4_none", "cursor-cli": "s4_none", warp: "s4_none", trae: "s4_none", "kimi-cli": "s4_none", "qwen-code": "s4_none", "kiro-cli": "s4_none", amp: "s4_none", crush: "s4_none", "iflow-cli": "s4_none", qoder: "s4_none", workbuddy: "s4_none", cowork: "s4_none", pi: "s4_none", | ||
@@ -35,5 +35,5 @@ }; | ||
| codex: { mcpMode: "auto", skillMode: "shared_skill", invocation: "skill_picker", verification: "read_only_probe", guide: guide("Codex", "Restart Codex and use its Skill picker to select SandBase; do not assume a slash command.", "SandBase MCP tools are visible in the session."), uninstall: "managed_only", terminal: "action_required" }, | ||
| "claude-code": { mcpMode: "auto", skillMode: "shared_skill", invocation: "mcp_chat", verification: "read_only_probe", guide: guide("Claude Code", "Restart Claude Code and use its configured MCP tools; native Skill discovery is not claimed.", "SandBase MCP tools are visible in chat."), uninstall: "managed_only", terminal: "action_required" }, | ||
| "claude-code": { mcpMode: "auto", skillMode: "client_skill", invocation: "slash", verification: "real_client_matrix", guide: guide("Claude Code", "Restart Claude Code; native Skill discovery remains unverified until the real-client matrix is complete.", "SandBase MCP tools and the private Skill are installed."), uninstall: "managed_only", terminal: "action_required" }, | ||
| cursor: { mcpMode: "auto", skillMode: "shared_skill", invocation: "slash", verification: "real_client_matrix", guide: guide("Cursor", "Restart Cursor, type /, and look for /sandbase.", "A real-client matrix confirms /sandbase discovery and invocation."), uninstall: "managed_only", terminal: "action_required" }, | ||
| "cursor-cli": { mcpMode: "auto", skillMode: "none", invocation: "mcp_chat", verification: "read_only_probe", guide: guide("Cursor CLI", "Restart the CLI session and use the configured SandBase MCP tools.", "SandBase tools are listed by the client."), uninstall: "managed_only", terminal: "action_required" }, | ||
| "cursor-cli": { mcpMode: "none", skillMode: "none", invocation: "none", verification: "user_action", guide: guide("Cursor CLI", "No independent MCP configuration schema is available yet.", "Do not reuse desktop Cursor configuration."), uninstall: "managed_only", terminal: "action_required" }, | ||
| "gemini-cli": { mcpMode: "auto", skillMode: "none", invocation: "mcp_chat", verification: "read_only_probe", guide: guide("Gemini CLI", "Restart the CLI session and use the configured SandBase MCP tools.", "SandBase tools are listed by the client."), uninstall: "managed_only", terminal: "action_required" }, | ||
@@ -60,2 +60,23 @@ hermes: { mcpMode: "auto", skillMode: "prompt", invocation: "mcp_chat", verification: "read_only_probe", guide: guide("Hermes", "Restart Hermes, then ask it to use the configured SandBase MCP tools.", "Hermes lists or calls SandBase tools."), uninstall: "managed_only", terminal: "action_required" }, | ||
| }; | ||
| function v2(client, capability) { | ||
| const evidence = { | ||
| codex: { source: "https://developers.openai.com/codex/skills/", retrieved: "2026-07-29", conclusion: "Codex supports SKILL.md; the existing B-063 adapter provides the MCP transaction.", kind: "first_party" }, | ||
| "claude-code": { source: "https://docs.anthropic.com/en/docs/claude-code/skills", retrieved: "2026-07-29", conclusion: "Personal Skills use ~/.claude/skills/<skill-name>/SKILL.md and the directory name maps to its command.", kind: "first_party" }, | ||
| cursor: { source: "https://docs.cursor.com/context/skills", retrieved: "2026-07-28", conclusion: "The user-level ~/.agents/skills root is discovered by Cursor; UI discovery remains subject to the real-client matrix.", kind: "first_party" }, | ||
| }; | ||
| const implemented = client === "codex" || client === "claude-code" || client === "cursor"; | ||
| const blocker = client === "cursor-cli" ? "Cursor CLI MCP installation is blocked until an independent first-party schema is recorded; desktop Cursor configuration must not be reused." : "This client batch has no completed installer and validator in the current iteration."; | ||
| if (!implemented) | ||
| return { | ||
| evidence: { source: "docs/design/features/REQ-20260728-e4a8c1b9d2f0-all-agent-native-capabilities/design.md#2.1", retrieved: "2026-07-29", conclusion: blocker, kind: "internal_blocker" }, | ||
| installer: { mcp: "none", skill: "none" }, validator: { mcp: "none", skill: "none" }, implementation: "blocked", status: "failed", nextStep: blocker, | ||
| }; | ||
| const hasSkill = capability.skillMode === "shared_skill" || capability.skillMode === "client_skill"; | ||
| return { | ||
| evidence: evidence[client], installer: { mcp: "adapter", skill: client === "claude-code" ? "private" : "shared" }, | ||
| validator: { mcp: "read_back", skill: client === "cursor" || client === "claude-code" ? "real_client_matrix" : "ownership_checksum" }, implementation: "implemented", status: "confirmation_required", | ||
| nextStep: capability.guide, | ||
| }; | ||
| } | ||
| export const capabilityRegistry = Object.fromEntries(Object.keys(clientProfiles).map(client => [client, v2(client, nativeCapabilities[client])])); | ||
| export function assertNativeCapabilities() { | ||
@@ -66,2 +87,15 @@ for (const client of Object.keys(clientProfiles)) { | ||
| throw new Error(`Invalid native capability for ${client}`); | ||
| const v2 = capabilityRegistry[client]; | ||
| if (!v2?.evidence.source || !v2.evidence.retrieved || !v2.evidence.conclusion || !v2.evidence.kind || !v2.installer || !v2.validator || !v2.implementation || !v2.status || !v2.nextStep) | ||
| throw new Error(`Invalid capability v2 for ${client}`); | ||
| if (v2.implementation === "blocked" && (v2.status !== "failed" || v2.installer.mcp !== "none" || v2.installer.skill !== "none" || v2.validator.mcp !== "none" || v2.validator.skill !== "none")) | ||
| throw new Error(`Blocked capability must not be exposed as implemented for ${client}`); | ||
| if (v2.implementation === "implemented" && v2.evidence.kind !== "first_party") | ||
| throw new Error(`Implemented capability lacks first-party evidence for ${client}`); | ||
| if (v2.status === "unsupported" || v2.status === "confirmation_required") { | ||
| if (v2.status === "unsupported" && !v2.evidence.conclusion) | ||
| throw new Error(`Unsupported capability lacks evidence for ${client}`); | ||
| } | ||
| if (v2.status === "confirmation_required" && v2.installer.mcp === "none" && v2.installer.skill === "none") | ||
| throw new Error(`Confirmation cannot replace an installer for ${client}`); | ||
| } | ||
@@ -74,3 +108,3 @@ } | ||
| export function autoClients() { | ||
| return Object.values(clientProfiles).filter(profile => profile.mode === "auto" && !!profile.adapter).map(profile => profile.id); | ||
| return Object.values(clientProfiles).filter(profile => profile.mode === "auto" && !!profile.adapter && capabilityRegistry[profile.id].implementation === "implemented").map(profile => profile.id); | ||
| } |
+27
-12
@@ -6,3 +6,3 @@ import { spawn } from "node:child_process"; | ||
| import { authorize } from "./auth/flow.js"; | ||
| import { autoClients, clientProfiles, nativeCapabilities } from "./clients.js"; | ||
| import { autoClients, capabilityRegistry, clientProfiles, nativeCapabilities } from "./clients.js"; | ||
| import { FileCredentialStore } from "./credentials/store.js"; | ||
@@ -24,8 +24,8 @@ import { configure, detectClient, installBridge, isConfigured, rollback, rollbackBridge, unregister as removeAdapter } from "./adapters/index.js"; | ||
| 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) { | ||
| function successMessage(client, bridgePath) { | ||
| 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})`, "Credential was stored locally with restricted permissions."]; | ||
| 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, "", "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") | ||
@@ -39,5 +39,6 @@ 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"); | ||
| const capability = nativeCapabilities[client]; | ||
| if (capability.mcpMode === "auto" || capability.mcpMode === "desktop" || !detect(client).installed) | ||
| const v2 = capabilityRegistry[client]; | ||
| if (!detect(client).installed || (v2.implementation === "implemented" && (capability.mcpMode === "auto" || capability.mcpMode === "desktop"))) | ||
| continue; | ||
| log(`${client}: status=${capability.terminal}, mcp=not_configured, skill=${capability.skillMode}, invocation=${capability.invocation}. ${capability.guide}`); | ||
| log(`${client}: status=${v2.status}, mcp=not_configured, skill=not_configured, invocation=${capability.invocation}, next_step=${v2.nextStep}`); | ||
| } | ||
@@ -53,7 +54,10 @@ } | ||
| const result = await installSkill(client); | ||
| log(`Native Skill for ${client}: ${result.state}. ${result.message}`); | ||
| const status = result.state === "already_configured" ? "already_configured" : result.state === "configured" ? "configured" : "confirmation_required"; | ||
| log(`${client}: status=${status}, mcp=configured, skill=${result.state}, invocation=${nativeCapabilities[client].invocation}, next_step=${result.message}`); | ||
| return status; | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : "Unknown native Skill failure"; | ||
| log(`Native Skill for ${client}: failed. ${message}`); | ||
| log(`${client}: status=failed, mcp=configured, skill=failed, invocation=${nativeCapabilities[client].invocation}, next_step=${message}`); | ||
| return "failed"; | ||
| } | ||
@@ -68,4 +72,10 @@ } | ||
| const capability = nativeCapabilities[client]; | ||
| const v2 = capabilityRegistry[client]; | ||
| if (v2.implementation === "blocked") { | ||
| log(`${client}: status=failed, mcp=not_configured, skill=not_configured, invocation=${capability.invocation}, next_step=${v2.nextStep}`); | ||
| return; | ||
| } | ||
| if (capability.mcpMode === "manual" || capability.mcpMode === "none") { | ||
| log(`${client}: status=${capability.terminal}, mcp=not_configured, skill=${capability.skillMode}, invocation=${capability.invocation}. ${capability.guide}`); | ||
| const v2 = capabilityRegistry[client]; | ||
| log(`${client}: status=${v2.status}, mcp=not_configured, skill=not_configured, invocation=${capability.invocation}, next_step=${v2.nextStep}`); | ||
| return; | ||
@@ -87,3 +97,3 @@ } | ||
| throw new Error("Client configuration verification failed"); | ||
| log(successMessage(client, record, bridgeResult.path, configured.path)); | ||
| log(successMessage(client, bridgeResult.path)); | ||
| await configureSkill(client, log); | ||
@@ -172,5 +182,6 @@ exchange.cleanup_token = ""; | ||
| const capability = nativeCapabilities[target]; | ||
| const skillDetail = skill === "installed" ? skillInvocation(target) : skill === "fallback" ? skillFallback(target) : skill === "unsupported" ? "No native SandBase Skill is available for this client." : skill === "modified" ? "Managed native Skill is missing or modified; it was left untouched." : capability.guide; | ||
| const skillDetail = skill === "installed" ? skillInvocation(target) : skill === "fallback" ? skillFallback(target) : skill === "unsupported" ? "No native SandBase Skill is available for this client." : skill === "modified" ? "Managed native Skill is missing or modified; it was left untouched." : capabilityRegistry[target].nextStep; | ||
| const references = await sharedSkillReferences(); | ||
| console.log(`${target}: status=${capability.terminal}, mode=${profile.mode}, installed=${detected.installed ? "yes" : "no"}, config=${configured ? profile.mode === "auto" ? "ok" : "manual" : "missing"}, skill=${skill}, invocation=${capability.invocation}, verification=${capability.verification}, shared_skill_references=${references.join(",") || "none"}, credential=${credential ? credential.keyPrefix + "…" : "missing"}, url=${credential?.mcpUrl || "unknown"}, scope=${credential?.scope.join(",") || "unknown"}. ${skillDetail}`); | ||
| const status = configured && !!credential && skill === "installed" ? "configured" : "failed"; | ||
| console.log(`${target}: status=${status}, mcp=${configured ? "configured" : "not_configured"}, skill=${skill}, invocation=${capability.invocation}, verification=${capability.verification}, shared_skill_references=${references.join(",") || "none"}, next_step=${skillDetail}`); | ||
| healthy = healthy && detected.installed && !!credential && configured && !["missing", "modified"].includes(skill); | ||
@@ -187,2 +198,6 @@ } | ||
| for (const target of targets) { | ||
| if (capabilityRegistry[target].implementation === "blocked") { | ||
| console.log(`${target}: status=failed, next_step=${capabilityRegistry[target].nextStep}`); | ||
| continue; | ||
| } | ||
| const removed = await removeAdapter(target); | ||
@@ -189,0 +204,0 @@ const removedSkill = await removeSkill(target); |
+2
-0
@@ -8,2 +8,4 @@ import type { Client } from "./types.js"; | ||
| backup?: string; | ||
| metadataPath?: string; | ||
| metadataBackup?: string | undefined; | ||
| changed: boolean; | ||
@@ -10,0 +12,0 @@ } |
+93
-7
@@ -11,2 +11,3 @@ import { createHash } from "node:crypto"; | ||
| const sharedClients = ["cursor", "codex"]; | ||
| const privateClients = ["claude-code"]; | ||
| function sha256(content) { return createHash("sha256").update(content, "utf8").digest("hex"); } | ||
@@ -16,2 +17,5 @@ function sharedRoot(env = process.env) { const home = env.HOME || sandbaseHome(env); return join(home, ".agents", "skills", "sandbase"); } | ||
| function metadataPath(env = process.env) { return join(sharedRoot(env), ".sandbase-managed.json"); } | ||
| function privateRoot(client, env = process.env) { const home = env.HOME || sandbaseHome(env); return client === "claude-code" ? join(home, ".claude", "skills", "sandbase") : undefined; } | ||
| function privateSkillPath(client, env = process.env) { const root = privateRoot(client, env); return root ? join(root, "SKILL.md") : undefined; } | ||
| function privateMetadataPath(client, env = process.env) { const root = privateRoot(client, env); return root ? join(root, ".sandbase-managed.json") : undefined; } | ||
| function legacySkillPath(client, env = process.env) { | ||
@@ -25,3 +29,3 @@ const home = env.HOME || sandbaseHome(env); | ||
| } | ||
| export function skillPath(client, env = process.env) { return sharedClients.includes(client) ? sharedSkillPath(env) : undefined; } | ||
| export function skillPath(client, env = process.env) { return sharedClients.includes(client) ? sharedSkillPath(env) : privateSkillPath(client, env); } | ||
| export function skillFallback(client) { | ||
@@ -32,4 +36,4 @@ if (client === "claude-code" || client === "hermes") | ||
| } | ||
| export function skillInvocation(client) { return `${clientProfiles[client].label} uses the shared SandBase Skill. Restart the client, type /, and look for /sandbase; real-client verification is pending.`; } | ||
| function nativeTier(client) { return skillTiers[client] === "s3_promotion"; } | ||
| export function skillInvocation(client) { return `${clientProfiles[client].label} has a SandBase Skill artifact installed. Native discovery remains unverified until the real-client matrix is complete.`; } | ||
| function nativeTier(client) { return skillTiers[client] === "s3_promotion" || skillTiers[client] === "s1_slash"; } | ||
| function marked(content) { return content.includes(ownershipMarker) && /^name:\s*sandbase\s*$/m.test(content); } | ||
@@ -61,2 +65,20 @@ async function asset(relative = "../assets/skills/sandbase/SKILL.md") { | ||
| } | ||
| async function privateOwnership(client, env = process.env) { | ||
| const skill = privateSkillPath(client, env); | ||
| const metadata = privateMetadataPath(client, env); | ||
| if (!skill || !metadata) | ||
| return "missing"; | ||
| const [content, meta] = await Promise.all([readOptional(skill), readOptional(metadata)]); | ||
| if (!content && !meta) | ||
| return "missing"; | ||
| if (!content || !meta || !marked(content)) | ||
| return "ambiguous"; | ||
| try { | ||
| const parsed = JSON.parse(meta); | ||
| return parsed.owner === "sandbase-cli" && parsed.client === client && parsed.sha256 === sha256(content) ? "owned" : "ambiguous"; | ||
| } | ||
| catch { | ||
| return "ambiguous"; | ||
| } | ||
| } | ||
| async function withLock(env, work) { | ||
@@ -85,3 +107,3 @@ const lock = join(dirname(sharedRoot(env)), ".sandbase-skill.lock"); | ||
| const copies = []; | ||
| for (const client of sharedClients) { | ||
| for (const client of ["cursor", "codex"]) { | ||
| const path = legacySkillPath(client, env); | ||
@@ -102,2 +124,7 @@ const content = await readOptional(path); | ||
| try { | ||
| if (privateClients.includes(client)) { | ||
| if ((await privateOwnership(client, env)) === "ambiguous") | ||
| return { compatible: false, message: `The ${clientProfiles[client].label} SandBase Skill ownership or checksum is invalid. It was left untouched; resolve it manually, then retry.` }; | ||
| return { compatible: true, message: `${clientProfiles[client].label} personal Skills root is available for installation.` }; | ||
| } | ||
| if ((await sharedOwnership(env)) === "ambiguous") | ||
@@ -121,2 +148,6 @@ return { compatible: false, message: "The shared SandBase Skill ownership or checksum is invalid. It was left untouched; resolve it manually, then retry." }; | ||
| return "unsupported"; | ||
| if (privateClients.includes(client)) { | ||
| const ownership = await privateOwnership(client, env); | ||
| return ownership === "owned" ? "installed" : ownership === "missing" ? "missing" : "modified"; | ||
| } | ||
| const ownership = await sharedOwnership(env); | ||
@@ -130,2 +161,4 @@ return ownership === "owned" ? "installed" : ownership === "missing" ? "missing" : "modified"; | ||
| } | ||
| if (privateClients.includes(client)) | ||
| return installPrivateSkill(client, env); | ||
| return withLock(env, async () => { | ||
@@ -162,10 +195,42 @@ const probe = await probeSkill(client, env); | ||
| const changed = needsWrite || copies.length > 0; | ||
| return skillBackup ? { state: changed ? "configured" : "already_configured", message: skillInvocation(client), path: skillPath, backup: skillBackup, changed } : { state: changed ? "configured" : "already_configured", message: skillInvocation(client), path: skillPath, changed }; | ||
| return skillBackup ? { state: changed ? "configured" : "already_configured", message: skillInvocation(client), path: skillPath, backup: skillBackup, metadataPath: metaPath, metadataBackup: metaBackup, changed } : { state: changed ? "configured" : "already_configured", message: skillInvocation(client), path: skillPath, metadataPath: metaPath, metadataBackup: metaBackup, changed }; | ||
| }); | ||
| } | ||
| export async function rollbackSkill(result) { if (result.changed && result.path) | ||
| await restore(result.path, result.backup); } | ||
| async function installPrivateSkill(client, env) { | ||
| const path = privateSkillPath(client, env); | ||
| const meta = privateMetadataPath(client, env); | ||
| return withLock(env, async () => { | ||
| const probe = await probeSkill(client, env); | ||
| if (!probe.compatible) | ||
| throw new Error(probe.message); | ||
| const desired = await asset(); | ||
| const desiredMeta = JSON.stringify({ owner: "sandbase-cli", client, sha256: sha256(desired) }) + "\n"; | ||
| const current = await readOptional(path); | ||
| const needsWrite = current !== desired || await privateOwnership(client, env) !== "owned"; | ||
| const skillBackup = await backup(path); | ||
| const metaBackup = await backup(meta); | ||
| try { | ||
| if (needsWrite) { | ||
| await atomicWrite(path, desired, 0o600); | ||
| await atomicWrite(meta, desiredMeta, 0o600); | ||
| } | ||
| if (await privateOwnership(client, env) !== "owned") | ||
| throw new Error(`${clientProfiles[client].label} native Skill verification failed`); | ||
| } | ||
| catch (error) { | ||
| await restore(path, skillBackup); | ||
| await restore(meta, metaBackup); | ||
| throw error; | ||
| } | ||
| return skillBackup ? { state: needsWrite ? "configured" : "already_configured", message: `${clientProfiles[client].label} personal SandBase Skill is installed; /sandbase still requires real-client verification.`, path, backup: skillBackup, metadataPath: meta, metadataBackup: metaBackup, changed: needsWrite } : { state: needsWrite ? "configured" : "already_configured", message: `${clientProfiles[client].label} personal SandBase Skill is installed; /sandbase still requires real-client verification.`, path, metadataPath: meta, metadataBackup: metaBackup, changed: needsWrite }; | ||
| }); | ||
| } | ||
| export async function rollbackSkill(result) { if (!result.changed || !result.path) | ||
| return; await restore(result.path, result.backup); if (result.metadataPath) | ||
| await restore(result.metadataPath, result.metadataBackup); } | ||
| export async function removeSkill(client, env = process.env) { | ||
| if (!nativeTier(client)) | ||
| return false; | ||
| if (privateClients.includes(client)) | ||
| return removePrivateSkill(client, env); | ||
| return withLock(env, async () => { | ||
@@ -195,1 +260,22 @@ if ((await sharedSkillReferences(env)).length) | ||
| } | ||
| async function removePrivateSkill(client, env) { | ||
| const path = privateSkillPath(client, env); | ||
| const meta = privateMetadataPath(client, env); | ||
| const ownership = await privateOwnership(client, env); | ||
| if (ownership === "missing") | ||
| return false; | ||
| if (ownership !== "owned") | ||
| throw new Error(`The ${clientProfiles[client].label} SandBase Skill ownership or checksum is invalid. It was left untouched; remove it manually if intended.`); | ||
| const skillBackup = await backup(path); | ||
| const metaBackup = await backup(meta); | ||
| try { | ||
| await rm(path); | ||
| await rm(meta); | ||
| } | ||
| catch (error) { | ||
| await restore(path, skillBackup); | ||
| await restore(meta, metaBackup); | ||
| throw error; | ||
| } | ||
| return true; | ||
| } |
+1
-1
| { | ||
| "name": "@sandbaseai/cli", | ||
| "version": "0.1.5", | ||
| "version": "0.1.6", | ||
| "description": "Secure SandBase MCP onboarding CLI", | ||
@@ -5,0 +5,0 @@ "type": "module", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
74026
20.24%1170
20.74%29
16%