feat: add download installs for skills
This commit is contained in:
@@ -111,7 +111,7 @@ Fields under `metadata.clawdbot`:
|
|||||||
- `requires.env` — list; env var must exist **or** be provided in config.
|
- `requires.env` — list; env var must exist **or** be provided in config.
|
||||||
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
- `requires.config` — list of `clawdbot.json` paths that must be truthy.
|
||||||
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
|
- `primaryEnv` — env var name associated with `skills.entries.<name>.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:
|
Note on sandboxing:
|
||||||
- `requires.bins` is checked on the **host** at skill load time.
|
- `requires.bins` is checked on the **host** at skill load time.
|
||||||
@@ -134,10 +134,13 @@ metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install"
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If multiple installers are listed, the gateway picks a **single** preferred option (brew when available, otherwise node).
|
- 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).
|
- 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
|
This only affects **skill installs**; the Gateway runtime should still be Node
|
||||||
(Bun is not recommended for WhatsApp/Telegram).
|
(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.
|
- 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/<skillKey>`).
|
||||||
|
|
||||||
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
If no `metadata.clawdbot` is present, the skill is always eligible (unless
|
||||||
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
disabled in config or blocked by `skills.allowBundled` for bundled skills).
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveBrewExecutable } from "../infra/brew.js";
|
import { resolveBrewExecutable } from "../infra/brew.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
hasBinary,
|
hasBinary,
|
||||||
loadWorkspaceSkillEntries,
|
loadWorkspaceSkillEntries,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
type SkillInstallSpec,
|
type SkillInstallSpec,
|
||||||
type SkillsInstallPreferences,
|
type SkillsInstallPreferences,
|
||||||
} from "./skills.js";
|
} from "./skills.js";
|
||||||
|
import { resolveSkillKey } from "./skills/frontmatter.js";
|
||||||
|
|
||||||
export type SkillInstallRequest = {
|
export type SkillInstallRequest = {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
@@ -112,11 +115,163 @@ function buildInstallCommand(
|
|||||||
if (!spec.package) return { argv: null, error: "missing uv package" };
|
if (!spec.package) return { argv: null, error: "missing uv package" };
|
||||||
return { argv: ["uv", "tool", "install", spec.package] };
|
return { argv: ["uv", "tool", "install", spec.package] };
|
||||||
}
|
}
|
||||||
|
case "download": {
|
||||||
|
return { argv: null, error: "download install handled separately" };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { argv: null, error: "unsupported installer" };
|
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<typeof Readable.fromWeb>[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<SkillInstallResult> {
|
||||||
|
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<string | undefined> {
|
async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise<string | undefined> {
|
||||||
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
||||||
if (!exe) return undefined;
|
if (!exe) return undefined;
|
||||||
@@ -167,6 +322,9 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
|
|||||||
code: null,
|
code: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.kind === "download") {
|
||||||
|
return await installDownloadSpec({ entry, spec, timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
const prefs = resolveSkillsInstallPreferences(params.config);
|
const prefs = resolveSkillsInstallPreferences(params.config);
|
||||||
const command = buildInstallCommand(spec, prefs);
|
const command = buildInstallCommand(spec, prefs);
|
||||||
|
|||||||
@@ -100,9 +100,15 @@ function normalizeInstallOptions(
|
|||||||
): SkillInstallOption[] {
|
): SkillInstallOption[] {
|
||||||
const install = entry.clawdbot?.install ?? [];
|
const install = entry.clawdbot?.install ?? [];
|
||||||
if (install.length === 0) return [];
|
if (install.length === 0) return [];
|
||||||
const preferred = selectPreferredInstallSpec(install, prefs);
|
|
||||||
if (!preferred) return [];
|
const platform = process.platform;
|
||||||
const { spec, index } = preferred;
|
const filtered = install.filter((spec) => {
|
||||||
|
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 id = (spec.id ?? `${spec.kind}-${index}`).trim();
|
||||||
const bins = spec.bins ?? [];
|
const bins = spec.bins ?? [];
|
||||||
let label = (spec.label ?? "").trim();
|
let label = (spec.label ?? "").trim();
|
||||||
@@ -118,18 +124,25 @@ function normalizeInstallOptions(
|
|||||||
label = `Install ${spec.module} (go)`;
|
label = `Install ${spec.module} (go)`;
|
||||||
} else if (spec.kind === "uv" && spec.package) {
|
} else if (spec.kind === "uv" && spec.package) {
|
||||||
label = `Install ${spec.package} (uv)`;
|
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 {
|
} else {
|
||||||
label = "Run installer";
|
label = "Run installer";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [
|
return { id, kind: spec.kind, label, bins };
|
||||||
{
|
};
|
||||||
id,
|
|
||||||
kind: spec.kind,
|
const allDownloads = filtered.every((spec) => spec.kind === "download");
|
||||||
label,
|
if (allDownloads) {
|
||||||
bins,
|
return filtered.map((spec, index) => toOption(spec, index));
|
||||||
},
|
}
|
||||||
];
|
|
||||||
|
const preferred = selectPreferredInstallSpec(filtered, prefs);
|
||||||
|
if (!preferred) return [];
|
||||||
|
return [toOption(preferred.spec, preferred.index)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSkillStatus(
|
function buildSkillStatus(
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
|||||||
const kindRaw =
|
const kindRaw =
|
||||||
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
|
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
|
||||||
const kind = kindRaw.trim().toLowerCase();
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +47,16 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
|||||||
if (typeof raw.label === "string") spec.label = raw.label;
|
if (typeof raw.label === "string") spec.label = raw.label;
|
||||||
const bins = normalizeStringList(raw.bins);
|
const bins = normalizeStringList(raw.bins);
|
||||||
if (bins.length > 0) spec.bins = 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.formula === "string") spec.formula = raw.formula;
|
||||||
if (typeof raw.package === "string") spec.package = raw.package;
|
if (typeof raw.package === "string") spec.package = raw.package;
|
||||||
if (typeof raw.module === "string") spec.module = raw.module;
|
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;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
export type SkillInstallSpec = {
|
export type SkillInstallSpec = {
|
||||||
id?: string;
|
id?: string;
|
||||||
kind: "brew" | "node" | "go" | "uv";
|
kind: "brew" | "node" | "go" | "uv" | "download";
|
||||||
label?: string;
|
label?: string;
|
||||||
bins?: string[];
|
bins?: string[];
|
||||||
|
os?: string[];
|
||||||
formula?: string;
|
formula?: string;
|
||||||
package?: string;
|
package?: string;
|
||||||
module?: string;
|
module?: string;
|
||||||
|
url?: string;
|
||||||
|
archive?: string;
|
||||||
|
extract?: boolean;
|
||||||
|
stripComponents?: number;
|
||||||
|
targetDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClawdbotSkillMetadata = {
|
export type ClawdbotSkillMetadata = {
|
||||||
|
|||||||
Reference in New Issue
Block a user