diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 770f8066f..e1a5c25d9 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`: - `requires.env` — list; env var must exist **or** be provided in config. - `requires.config` — list of `clawdbot.json` paths that must be truthy. - `primaryEnv` — env var name associated with `skills.entries..apiKey`. -- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv). +- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv/download). Note on sandboxing: - `requires.bins` is checked on the **host** at skill load time. @@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install" Notes: - If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node). +- If all installers are `download`, Clawdbot lists each entry so you can see the available artifacts. +- Installer specs can include `os: ["darwin"|"linux"|"win32"]` to filter options by platform. - Node installs honor `skills.install.nodeManager` in `clawdbot.json` (default: npm; options: npm/pnpm/yarn/bun). This only affects **skill installs**; the Gateway runtime should still be Node (Bun is not recommended for WhatsApp/Telegram). - Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible. + - Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/`). If no `metadata.clawdbot` is present, the skill is always eligible (unless disabled in config or blocked by `skills.allowBundled` for bundled skills). diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index 3f30cf66a..4cbc2234b 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -1,10 +1,12 @@ import fs from "node:fs"; import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveBrewExecutable } from "../infra/brew.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { resolveUserPath } from "../utils.js"; +import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js"; import { hasBinary, loadWorkspaceSkillEntries, @@ -13,6 +15,7 @@ import { type SkillInstallSpec, type SkillsInstallPreferences, } from "./skills.js"; +import { resolveSkillKey } from "./skills/frontmatter.js"; export type SkillInstallRequest = { workspaceDir: string; @@ -112,11 +115,163 @@ function buildInstallCommand( if (!spec.package) return { argv: null, error: "missing uv package" }; return { argv: ["uv", "tool", "install", spec.package] }; } + case "download": { + return { argv: null, error: "download install handled separately" }; + } default: return { argv: null, error: "unsupported installer" }; } } +function resolveDownloadTargetDir(entry: SkillEntry, spec: SkillInstallSpec): string { + if (spec.targetDir?.trim()) return resolveUserPath(spec.targetDir); + const key = resolveSkillKey(entry.skill, entry); + return path.join(CONFIG_DIR, "tools", key); +} + +function resolveArchiveType(spec: SkillInstallSpec, filename: string): string | undefined { + const explicit = spec.archive?.trim().toLowerCase(); + if (explicit) return explicit; + const lower = filename.toLowerCase(); + if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz"; + if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2"; + if (lower.endsWith(".zip")) return "zip"; + return undefined; +} + +async function downloadFile( + url: string, + destPath: string, + timeoutMs: number, +): Promise<{ bytes: number }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs)); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok || !response.body) { + throw new Error(`Download failed (${response.status} ${response.statusText})`); + } + await ensureDir(path.dirname(destPath)); + const file = fs.createWriteStream(destPath); + const body = response.body; + const readable = + typeof (body as NodeJS.ReadableStream).pipe === "function" + ? (body as NodeJS.ReadableStream) + : Readable.fromWeb(body as Parameters[0]); + await pipeline(readable, file); + const stat = await fs.promises.stat(destPath); + return { bytes: stat.size }; + } finally { + clearTimeout(timeout); + } +} + +async function extractArchive(params: { + archivePath: string; + archiveType: string; + targetDir: string; + stripComponents?: number; + timeoutMs: number; +}): Promise<{ stdout: string; stderr: string; code: number | null }> { + const { archivePath, archiveType, targetDir, stripComponents, timeoutMs } = params; + if (archiveType === "zip") { + if (!hasBinary("unzip")) { + return { stdout: "", stderr: "unzip not found on PATH", code: null }; + } + const argv = ["unzip", "-q", archivePath, "-d", targetDir]; + return await runCommandWithTimeout(argv, { timeoutMs }); + } + + if (!hasBinary("tar")) { + return { stdout: "", stderr: "tar not found on PATH", code: null }; + } + const argv = ["tar", "xf", archivePath, "-C", targetDir]; + if (typeof stripComponents === "number" && Number.isFinite(stripComponents)) { + argv.push("--strip-components", String(Math.max(0, Math.floor(stripComponents)))); + } + return await runCommandWithTimeout(argv, { timeoutMs }); +} + +async function installDownloadSpec(params: { + entry: SkillEntry; + spec: SkillInstallSpec; + timeoutMs: number; +}): Promise { + const { entry, spec, timeoutMs } = params; + const url = spec.url?.trim(); + if (!url) { + return { + ok: false, + message: "missing download url", + stdout: "", + stderr: "", + code: null, + }; + } + + let filename = ""; + try { + const parsed = new URL(url); + filename = path.basename(parsed.pathname); + } catch { + filename = path.basename(url); + } + if (!filename) filename = "download"; + + const targetDir = resolveDownloadTargetDir(entry, spec); + await ensureDir(targetDir); + + const archivePath = path.join(targetDir, filename); + let downloaded = 0; + try { + const result = await downloadFile(url, archivePath, timeoutMs); + downloaded = result.bytes; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, message, stdout: "", stderr: message, code: null }; + } + + const archiveType = resolveArchiveType(spec, filename); + const shouldExtract = spec.extract ?? Boolean(archiveType); + if (!shouldExtract) { + return { + ok: true, + message: `Downloaded to ${archivePath}`, + stdout: `downloaded=${downloaded}`, + stderr: "", + code: 0, + }; + } + + if (!archiveType) { + return { + ok: false, + message: "extract requested but archive type could not be detected", + stdout: "", + stderr: "", + code: null, + }; + } + + const extractResult = await extractArchive({ + archivePath, + archiveType, + targetDir, + stripComponents: spec.stripComponents, + timeoutMs, + }); + const success = extractResult.code === 0; + return { + ok: success, + message: success + ? `Downloaded and extracted to ${targetDir}` + : formatInstallFailureMessage(extractResult), + stdout: extractResult.stdout.trim(), + stderr: extractResult.stderr.trim(), + code: extractResult.code, + }; +} + async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise { const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable()); if (!exe) return undefined; @@ -167,6 +322,9 @@ export async function installSkill(params: SkillInstallRequest): Promise { + const osList = spec.os ?? []; + return osList.length === 0 || osList.includes(platform); + }); + if (filtered.length === 0) return []; + + const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => { + const id = (spec.id ?? `${spec.kind}-${index}`).trim(); + const bins = spec.bins ?? []; + let label = (spec.label ?? "").trim(); + if (spec.kind === "node" && spec.package) { label = `Install ${spec.package} (${prefs.nodeManager})`; - } else if (spec.kind === "go" && spec.module) { - label = `Install ${spec.module} (go)`; - } else if (spec.kind === "uv" && spec.package) { - label = `Install ${spec.package} (uv)`; - } else { - label = "Run installer"; } + if (!label) { + if (spec.kind === "brew" && spec.formula) { + label = `Install ${spec.formula} (brew)`; + } else if (spec.kind === "node" && spec.package) { + label = `Install ${spec.package} (${prefs.nodeManager})`; + } else if (spec.kind === "go" && spec.module) { + label = `Install ${spec.module} (go)`; + } else if (spec.kind === "uv" && spec.package) { + label = `Install ${spec.package} (uv)`; + } else if (spec.kind === "download" && spec.url) { + const url = spec.url.trim(); + const last = url.split("/").pop(); + label = `Download ${last && last.length > 0 ? last : url}`; + } else { + label = "Run installer"; + } + } + return { id, kind: spec.kind, label, bins }; + }; + + const allDownloads = filtered.every((spec) => spec.kind === "download"); + if (allDownloads) { + return filtered.map((spec, index) => toOption(spec, index)); } - return [ - { - id, - kind: spec.kind, - label, - bins, - }, - ]; + + const preferred = selectPreferredInstallSpec(filtered, prefs); + if (!preferred) return []; + return [toOption(preferred.spec, preferred.index)]; } function buildSkillStatus( diff --git a/src/agents/skills.buildworkspaceskillstatus.test.ts b/src/agents/skills.buildworkspaceskillstatus.test.ts index 3a29d409e..c4d78c18d 100644 --- a/src/agents/skills.buildworkspaceskillstatus.test.ts +++ b/src/agents/skills.buildworkspaceskillstatus.test.ts @@ -109,4 +109,33 @@ describe("buildWorkspaceSkillStatus", () => { } } }); + + it("filters install options by OS", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-")); + const skillDir = path.join(workspaceDir, "skills", "install-skill"); + + await writeSkill({ + dir: skillDir, + name: "install-skill", + description: "OS-specific installs", + metadata: + '{"clawdbot":{"requires":{"bins":["missing-bin"]},"install":[{"id":"mac","kind":"download","os":["darwin"],"url":"https://example.com/mac.tar.bz2"},{"id":"linux","kind":"download","os":["linux"],"url":"https://example.com/linux.tar.bz2"},{"id":"win","kind":"download","os":["win32"],"url":"https://example.com/win.tar.bz2"}]}}', + }); + + const report = buildWorkspaceSkillStatus(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + const skill = report.skills.find((entry) => entry.name === "install-skill"); + + expect(skill).toBeDefined(); + if (process.platform === "darwin") { + expect(skill?.install.map((opt) => opt.id)).toEqual(["mac"]); + } else if (process.platform === "linux") { + expect(skill?.install.map((opt) => opt.id)).toEqual(["linux"]); + } else if (process.platform === "win32") { + expect(skill?.install.map((opt) => opt.id)).toEqual(["win"]); + } else { + expect(skill?.install).toEqual([]); + } + }); }); diff --git a/src/agents/skills/frontmatter.ts b/src/agents/skills/frontmatter.ts index a40c82b17..abc761db0 100644 --- a/src/agents/skills/frontmatter.ts +++ b/src/agents/skills/frontmatter.ts @@ -35,7 +35,7 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { const kindRaw = typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : ""; const kind = kindRaw.trim().toLowerCase(); - if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv") { + if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") { return undefined; } @@ -47,9 +47,16 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined { if (typeof raw.label === "string") spec.label = raw.label; const bins = normalizeStringList(raw.bins); if (bins.length > 0) spec.bins = bins; + const osList = normalizeStringList(raw.os); + if (osList.length > 0) spec.os = osList; if (typeof raw.formula === "string") spec.formula = raw.formula; if (typeof raw.package === "string") spec.package = raw.package; if (typeof raw.module === "string") spec.module = raw.module; + if (typeof raw.url === "string") spec.url = raw.url; + if (typeof raw.archive === "string") spec.archive = raw.archive; + if (typeof raw.extract === "boolean") spec.extract = raw.extract; + if (typeof raw.stripComponents === "number") spec.stripComponents = raw.stripComponents; + if (typeof raw.targetDir === "string") spec.targetDir = raw.targetDir; return spec; } diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index 1b39b77d6..9cda1bd6e 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -2,12 +2,18 @@ import type { Skill } from "@mariozechner/pi-coding-agent"; export type SkillInstallSpec = { id?: string; - kind: "brew" | "node" | "go" | "uv"; + kind: "brew" | "node" | "go" | "uv" | "download"; label?: string; bins?: string[]; + os?: string[]; formula?: string; package?: string; module?: string; + url?: string; + archive?: string; + extract?: boolean; + stripComponents?: number; + targetDir?: string; }; export type ClawdbotSkillMetadata = {