182 lines
5.8 KiB
TypeScript
182 lines
5.8 KiB
TypeScript
import { installSkill } from "../agents/skills-install.js";
|
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
|
import { formatCliCommand } from "../cli/command-format.js";
|
|
import type { ClawdbotConfig } from "../config/config.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
|
import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js";
|
|
|
|
function summarizeInstallFailure(message: string): string | undefined {
|
|
const cleaned = message.replace(/^Install failed(?:\s*\([^)]*\))?\s*:?\s*/i, "").trim();
|
|
if (!cleaned) return undefined;
|
|
const maxLen = 140;
|
|
return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}…` : cleaned;
|
|
}
|
|
|
|
function formatSkillHint(skill: {
|
|
description?: string;
|
|
install: Array<{ label: string }>;
|
|
}): string {
|
|
const desc = skill.description?.trim();
|
|
const installLabel = skill.install[0]?.label?.trim();
|
|
const combined = desc && installLabel ? `${desc} — ${installLabel}` : desc || installLabel;
|
|
if (!combined) return "install";
|
|
const maxLen = 90;
|
|
return combined.length > maxLen ? `${combined.slice(0, maxLen - 1)}…` : combined;
|
|
}
|
|
|
|
function upsertSkillEntry(
|
|
cfg: ClawdbotConfig,
|
|
skillKey: string,
|
|
patch: { apiKey?: string },
|
|
): ClawdbotConfig {
|
|
const entries = { ...cfg.skills?.entries };
|
|
const existing = (entries[skillKey] as { apiKey?: string } | undefined) ?? {};
|
|
entries[skillKey] = { ...existing, ...patch };
|
|
return {
|
|
...cfg,
|
|
skills: {
|
|
...cfg.skills,
|
|
entries,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function setupSkills(
|
|
cfg: ClawdbotConfig,
|
|
workspaceDir: string,
|
|
runtime: RuntimeEnv,
|
|
prompter: WizardPrompter,
|
|
): Promise<ClawdbotConfig> {
|
|
const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
|
|
const eligible = report.skills.filter((s) => s.eligible);
|
|
const missing = report.skills.filter((s) => !s.eligible && !s.disabled && !s.blockedByAllowlist);
|
|
const blocked = report.skills.filter((s) => s.blockedByAllowlist);
|
|
|
|
const needsBrewPrompt =
|
|
process.platform !== "win32" &&
|
|
report.skills.some((skill) => skill.install.some((option) => option.kind === "brew")) &&
|
|
!(await detectBinary("brew"));
|
|
|
|
await prompter.note(
|
|
[
|
|
`Eligible: ${eligible.length}`,
|
|
`Missing requirements: ${missing.length}`,
|
|
`Blocked by allowlist: ${blocked.length}`,
|
|
].join("\n"),
|
|
"Skills status",
|
|
);
|
|
|
|
const shouldConfigure = await prompter.confirm({
|
|
message: "Configure skills now? (recommended)",
|
|
initialValue: true,
|
|
});
|
|
if (!shouldConfigure) return cfg;
|
|
|
|
if (needsBrewPrompt) {
|
|
await prompter.note(
|
|
[
|
|
"Many skill dependencies are shipped via Homebrew.",
|
|
"Without brew, you'll need to build from source or download releases manually.",
|
|
].join("\n"),
|
|
"Homebrew recommended",
|
|
);
|
|
const showBrewInstall = await prompter.confirm({
|
|
message: "Show Homebrew install command?",
|
|
initialValue: true,
|
|
});
|
|
if (showBrewInstall) {
|
|
await prompter.note(
|
|
[
|
|
"Run:",
|
|
'/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
].join("\n"),
|
|
"Homebrew install",
|
|
);
|
|
}
|
|
}
|
|
|
|
const nodeManager = (await prompter.select({
|
|
message: "Preferred node manager for skill installs",
|
|
options: resolveNodeManagerOptions(),
|
|
})) as "npm" | "pnpm" | "bun";
|
|
|
|
let next: ClawdbotConfig = {
|
|
...cfg,
|
|
skills: {
|
|
...cfg.skills,
|
|
install: {
|
|
...cfg.skills?.install,
|
|
nodeManager,
|
|
},
|
|
},
|
|
};
|
|
|
|
const installable = missing.filter(
|
|
(skill) => skill.install.length > 0 && skill.missing.bins.length > 0,
|
|
);
|
|
if (installable.length > 0) {
|
|
const toInstall = await prompter.multiselect({
|
|
message: "Install missing skill dependencies",
|
|
options: [
|
|
{
|
|
value: "__skip__",
|
|
label: "Skip for now",
|
|
hint: "Continue without installing dependencies",
|
|
},
|
|
...installable.map((skill) => ({
|
|
value: skill.name,
|
|
label: `${skill.emoji ?? "🧩"} ${skill.name}`,
|
|
hint: formatSkillHint(skill),
|
|
})),
|
|
],
|
|
});
|
|
|
|
const selected = (toInstall as string[]).filter((name) => name !== "__skip__");
|
|
for (const name of selected) {
|
|
const target = installable.find((s) => s.name === name);
|
|
if (!target || target.install.length === 0) continue;
|
|
const installId = target.install[0]?.id;
|
|
if (!installId) continue;
|
|
const spin = prompter.progress(`Installing ${name}…`);
|
|
const result = await installSkill({
|
|
workspaceDir,
|
|
skillName: target.name,
|
|
installId,
|
|
config: next,
|
|
});
|
|
if (result.ok) {
|
|
spin.stop(`Installed ${name}`);
|
|
} else {
|
|
const code = result.code == null ? "" : ` (exit ${result.code})`;
|
|
const detail = summarizeInstallFailure(result.message);
|
|
spin.stop(`Install failed: ${name}${code}${detail ? ` — ${detail}` : ""}`);
|
|
if (result.stderr) runtime.log(result.stderr.trim());
|
|
else if (result.stdout) runtime.log(result.stdout.trim());
|
|
runtime.log(
|
|
`Tip: run \`${formatCliCommand("clawdbot doctor")}\` to review skills + requirements.`,
|
|
);
|
|
runtime.log("Docs: https://docs.clawd.bot/skills");
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const skill of missing) {
|
|
if (!skill.primaryEnv || skill.missing.env.length === 0) continue;
|
|
const wantsKey = await prompter.confirm({
|
|
message: `Set ${skill.primaryEnv} for ${skill.name}?`,
|
|
initialValue: false,
|
|
});
|
|
if (!wantsKey) continue;
|
|
const apiKey = String(
|
|
await prompter.text({
|
|
message: `Enter ${skill.primaryEnv}`,
|
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
}),
|
|
);
|
|
next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() });
|
|
}
|
|
|
|
return next;
|
|
}
|