UNPKG

browse

Version:

Unified Browserbase CLI for browser automation and cloud APIs.

320 lines (319 loc) 11.1 kB
import { spawn } from "node:child_process"; import { constants } from "node:fs"; import { access, mkdir, mkdtemp, rm, rename, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { fail } from "../errors.js"; import { defaultSkillsApiBaseUrl, isRecord, responseDetail } from "./shared.js"; const defaultBlobBaseUrl = "https://gh0lfhlmyzhg6tww.public.blob.vercel-storage.com"; const generatedSkillSuffixPattern = /-[A-Za-z0-9]{6}$/; const domainPattern = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/; const taskPattern = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/; const maxSkillFiles = 100; export function parseSkillId(rawSkillId) { const parts = rawSkillId.split("/"); if (parts.length !== 2) { fail("Skill must be in the form <domain>/<task>."); } const [domain, task] = parts; if (!domain || !task) { fail("Skill must be in the form <domain>/<task>."); } if (rawSkillId.includes("\\") || domain === "." || domain === ".." || task === "." || task === ".." || !domainPattern.test(domain) || !taskPattern.test(task)) { fail(`Invalid skill id "${rawSkillId}". Use <domain>/<task>.`); } return { domain, task, id: rawSkillId, }; } export function isBlobSkillId(skillId) { return generatedSkillSuffixPattern.test(skillId.task); } export async function installSkill(rawSkillId) { const skillId = parseSkillId(rawSkillId); const npxPath = await findExecutable("npx"); if (!npxPath) { fail("`npx` is not installed. Install Node.js from https://nodejs.org, then rerun `browse skills add`."); } const files = await fetchSkillFiles(skillId); if (files) { const result = await downloadBlobSkill(skillId, files); process.stdout.write(`Downloaded ${result.fileCount} skill file${result.fileCount === 1 ? "" : "s"} to ${result.installPath}\n`); return await spawnPassthrough(npxPath, [ "--yes", "skills", "add", result.installPath, ]); } return await spawnPassthrough(npxPath, [ "--yes", "skills", "add", "browserbase/browse.sh", "--skill", skillId.id, ]); } export async function installBundledCliSkill() { const npxPath = await findExecutable("npx"); if (!npxPath) { fail("`npx` is not installed. Install Node.js from https://nodejs.org, then rerun `browse skills install`."); } return await spawnPassthrough(npxPath, [ "--yes", "skills", "add", bundledCliSkillPath(), "--yes", "--global", "--agent", "*", ]); } export async function downloadBlobSkill(skillId, files) { const filesToDownload = files ?? (await fetchSkillFiles(skillId)); if (!filesToDownload) { fail(`Skill ${skillId.id} was not found as a generated skill.`); } const installPath = localSkillPath(skillId); const parentDir = dirname(installPath); await mkdir(parentDir, { recursive: true }); const tempDir = await mkdtemp(join(parentDir, `.${skillId.task}-`)); try { for (const file of filesToDownload) { const contents = await fetchSkillFile(file.url, `${skillId.id}/${file.path}`); const outputPath = join(tempDir, file.path); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, contents); } await rm(installPath, { recursive: true, force: true }); await rename(tempDir, installPath); } catch (error) { await rm(tempDir, { recursive: true, force: true }); throw error; } return { installPath, fileCount: filesToDownload.length, }; } function localSkillPath(skillId) { const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); return join(configHome, "browserbase", "skills", skillId.domain, skillId.task); } function bundledCliSkillPath() { return join(packageRoot(), "skills", "browse"); } function packageRoot() { return join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); } async function fetchSkillFiles(skillId) { const apiResult = await fetchSkillFilesFromApi(skillId); if (apiResult.status === "found") { return apiResult.files; } if (apiResult.status === "unavailable" && isBlobSkillId(skillId) && (await directBlobSkillExists(skillId))) { return [ { path: "SKILL.md", url: skillBlobUrl(skillId, "SKILL.md"), }, ]; } return null; } async function fetchSkillFilesFromApi(skillId) { const url = skillFilesApiUrl(skillId); let response; try { response = await fetch(url); } catch { return { status: "unavailable" }; } if (response.status === 404) { return { status: "not_found" }; } if (response.status >= 500) { return { status: "unavailable" }; } if (!response.ok) { fail(`Could not list files for ${skillId.id}: ${response.status} ${response.statusText}${await responseDetail(response)}`); } let payload; try { payload = await response.json(); } catch (error) { fail(`Could not parse file list for ${skillId.id}: ${error.message}`); } return { status: "found", files: validateApiSkillFiles(payload, skillId.id), }; } async function directBlobSkillExists(skillId) { let response; try { response = await fetch(skillBlobUrl(skillId, "SKILL.md"), { method: "HEAD", }); } catch { return false; } return response.ok; } function validateApiSkillFiles(payload, skillId) { if (!isRecord(payload) || !Array.isArray(payload.files)) { fail(`Invalid file list for ${skillId}: expected {"files":[{"path":"SKILL.md","url":"..."}]}.`); } if (typeof payload.skillId === "string" && payload.skillId !== skillId) { fail(`Invalid file list for ${skillId}: response was for ${payload.skillId}.`); } if (payload.files.length === 0) { fail(`Invalid file list for ${skillId}: files must include SKILL.md.`); } if (payload.files.length > maxSkillFiles) { fail(`Invalid file list for ${skillId}: expected ${maxSkillFiles} files or fewer.`); } const files = []; const seenPaths = new Set(); for (const file of payload.files) { if (!isRecord(file)) { fail(`Invalid file list for ${skillId}: file entries must include path and url.`); } const path = validateSkillFilePath(file.path, skillId); const url = validateSkillFileUrl(file.url, skillId, path); if (seenPaths.has(path)) { fail(`Invalid file list for ${skillId}: duplicate file path "${path}".`); } seenPaths.add(path); files.push({ path, url }); } if (!seenPaths.has("SKILL.md")) { fail(`Invalid file list for ${skillId}: files must include SKILL.md.`); } return files; } function validateSkillFilePath(value, skillId) { if (typeof value !== "string" || value.length === 0) { fail(`Invalid file list for ${skillId}: file paths must be non-empty strings.`); } if (value.startsWith("/") || value.includes("\\") || value .split("/") .some((segment) => segment === "" || segment === "." || segment === "..")) { fail(`Invalid file list for ${skillId}: unsafe file path "${value}".`); } return value; } function validateSkillFileUrl(value, skillId, path) { if (typeof value !== "string" || value.length === 0) { fail(`Invalid file list for ${skillId}: file "${path}" must include a URL.`); } let url; try { url = new URL(value); } catch { fail(`Invalid file list for ${skillId}: file "${path}" has an invalid URL.`); } if (url.protocol !== "https:" && url.protocol !== "http:") { fail(`Invalid file list for ${skillId}: file "${path}" must use an HTTP URL.`); } return url; } async function fetchSkillFile(url, label) { const response = await fetchFromUrl(url, label); return new Uint8Array(await response.arrayBuffer()); } async function fetchFromUrl(url, label) { let response; try { response = await fetch(url); } catch (error) { fail(`Could not download ${label}: ${error.message}`); } if (!response.ok) { fail(`Could not download ${label}: ${response.status} ${response.statusText}`); } return response; } function skillFilesApiUrl(skillId) { const baseUrl = process.env.BROWSE_SKILLS_API_BASE_URL || defaultSkillsApiBaseUrl; const pathname = ["api", "skills", skillId.domain, skillId.task, "files"] .map((segment) => encodeURIComponent(segment)) .join("/"); const url = new URL(pathname, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); const bypassToken = process.env.BROWSE_ALPHA_TOKEN; if (bypassToken && !url.searchParams.has("x-vercel-protection-bypass")) { url.searchParams.append("x-vercel-protection-bypass", bypassToken); } return url; } function skillBlobUrl(skillId, file) { const baseUrl = process.env.BROWSE_SKILLS_BLOB_BASE_URL || defaultBlobBaseUrl; const pathname = ["skills", skillId.domain, skillId.task, ...file.split("/")] .map((segment) => encodeURIComponent(segment)) .join("/"); return new URL(pathname, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); } async function findExecutable(command) { const pathEnv = process.env.PATH; if (!pathEnv) { return null; } const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM") .split(";") .filter(Boolean) : [""]; for (const segment of pathEnv.split(delimiter)) { for (const extension of extensions) { const candidate = join(segment, `${command}${extension.toLowerCase()}`); try { await access(candidate, constants.X_OK); return candidate; } catch { continue; } } } return null; } async function spawnPassthrough(command, args) { return await new Promise((resolvePromise, reject) => { const child = spawn(command, args, { stdio: "inherit", shell: shouldUseWindowsShell(command), }); child.on("error", reject); child.on("close", (exitCode, signal) => { if (signal) { resolvePromise(1); return; } resolvePromise(exitCode ?? 0); }); }); } export function shouldUseWindowsShell(command, platform = process.platform) { return platform === "win32" && /\.(?:bat|cmd)$/i.test(command); }