feat: add skills settings and gateway skills management

This commit is contained in:
Peter Steinberger
2025-12-20 13:33:06 +01:00
parent 4b44a75bc1
commit cc0075e988
19 changed files with 1142 additions and 546 deletions

View File

@@ -0,0 +1,147 @@
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import {
loadWorkspaceSkillEntries,
type SkillEntry,
type SkillInstallSpec,
} from "./skills.js";
export type SkillInstallRequest = {
workspaceDir: string;
skillName: string;
installId: string;
timeoutMs?: number;
};
export type SkillInstallResult = {
ok: boolean;
message: string;
stdout: string;
stderr: string;
code: number | null;
};
function resolveInstallId(spec: SkillInstallSpec, index: number): string {
return (spec.id ?? `${spec.kind}-${index}`).trim();
}
function findInstallSpec(
entry: SkillEntry,
installId: string,
): SkillInstallSpec | undefined {
const specs = entry.clawdis?.install ?? [];
for (const [index, spec] of specs.entries()) {
if (resolveInstallId(spec, index) === installId) return spec;
}
return undefined;
}
function runShell(command: string, timeoutMs: number) {
return runCommandWithTimeout(["/bin/zsh", "-lc", command], { timeoutMs });
}
function buildInstallCommand(spec: SkillInstallSpec): {
argv: string[] | null;
shell: string | null;
cwd?: string;
error?: string;
} {
switch (spec.kind) {
case "brew": {
if (!spec.formula) return { argv: null, shell: null, error: "missing brew formula" };
return { argv: ["brew", "install", spec.formula], shell: null };
}
case "node": {
if (!spec.package) return { argv: null, shell: null, error: "missing node package" };
return { argv: ["npm", "install", "-g", spec.package], shell: null };
}
case "go": {
if (!spec.module) return { argv: null, shell: null, error: "missing go module" };
return { argv: ["go", "install", spec.module], shell: null };
}
case "pnpm": {
if (!spec.repoPath || !spec.script) {
return { argv: null, shell: null, error: "missing pnpm repoPath/script" };
}
const repoPath = resolveUserPath(spec.repoPath);
const cmd = `cd ${JSON.stringify(repoPath)} && pnpm install && pnpm run ${JSON.stringify(spec.script)}`;
return { argv: null, shell: cmd };
}
case "git": {
if (!spec.url || !spec.destination) {
return { argv: null, shell: null, error: "missing git url/destination" };
}
const dest = resolveUserPath(spec.destination);
const cmd = `if [ -d ${JSON.stringify(dest)} ]; then echo "Already cloned"; else git clone ${JSON.stringify(spec.url)} ${JSON.stringify(dest)}; fi`;
return { argv: null, shell: cmd };
}
case "shell": {
if (!spec.command) return { argv: null, shell: null, error: "missing shell command" };
return { argv: null, shell: spec.command };
}
default:
return { argv: null, shell: null, error: "unsupported installer" };
}
}
export async function installSkill(
params: SkillInstallRequest,
): Promise<SkillInstallResult> {
const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 300_000, 1_000), 900_000);
const workspaceDir = resolveUserPath(params.workspaceDir);
const entries = loadWorkspaceSkillEntries(workspaceDir);
const entry = entries.find((item) => item.skill.name === params.skillName);
if (!entry) {
return {
ok: false,
message: `Skill not found: ${params.skillName}`,
stdout: "",
stderr: "",
code: null,
};
}
const spec = findInstallSpec(entry, params.installId);
if (!spec) {
return {
ok: false,
message: `Installer not found: ${params.installId}`,
stdout: "",
stderr: "",
code: null,
};
}
const command = buildInstallCommand(spec);
if (command.error) {
return {
ok: false,
message: command.error,
stdout: "",
stderr: "",
code: null,
};
}
if (!command.shell && (!command.argv || command.argv.length === 0)) {
return {
ok: false,
message: "invalid install command",
stdout: "",
stderr: "",
code: null,
};
}
const result = command.shell
? await runShell(command.shell, timeoutMs)
: await runCommandWithTimeout(command.argv, { timeoutMs, cwd: command.cwd });
const success = result.code === 0;
return {
ok: success,
message: success ? "Installed" : "Install failed",
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
code: result.code,
};
}

174
src/agents/skills-status.ts Normal file
View File

@@ -0,0 +1,174 @@
import path from "node:path";
import type { ClawdisConfig } from "../config/config.js";
import { CONFIG_DIR } from "../utils.js";
import {
hasBinary,
isConfigPathTruthy,
loadWorkspaceSkillEntries,
resolveConfigPath,
resolveSkillConfig,
type SkillEntry,
type SkillInstallSpec,
} from "./skills.js";
export type SkillStatusConfigCheck = {
path: string;
value: unknown;
satisfied: boolean;
};
export type SkillInstallOption = {
id: string;
kind: SkillInstallSpec["kind"];
label: string;
bins: string[];
};
export type SkillStatusEntry = {
name: string;
description: string;
source: string;
filePath: string;
baseDir: string;
skillKey: string;
primaryEnv?: string;
always: boolean;
disabled: boolean;
eligible: boolean;
requirements: {
bins: string[];
env: string[];
config: string[];
};
missing: {
bins: string[];
env: string[];
config: string[];
};
configChecks: SkillStatusConfigCheck[];
install: SkillInstallOption[];
};
export type SkillStatusReport = {
workspaceDir: string;
managedSkillsDir: string;
skills: SkillStatusEntry[];
};
function resolveSkillKey(entry: SkillEntry): string {
return entry.clawdis?.skillKey ?? entry.skill.name;
}
function normalizeInstallOptions(entry: SkillEntry): SkillInstallOption[] {
const install = entry.clawdis?.install ?? [];
if (install.length === 0) return [];
return install.map((spec, index) => {
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (!label) {
if (spec.kind === "brew" && spec.formula) {
label = `Install ${spec.formula} (brew)`;
} else if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (node)`;
} else if (spec.kind === "go" && spec.module) {
label = `Install ${spec.module} (go)`;
} else if (spec.kind === "pnpm" && spec.repoPath) {
label = `Install ${spec.repoPath} (pnpm)`;
} else if (spec.kind === "git" && spec.url) {
label = `Clone ${spec.url}`;
} else {
label = "Run installer";
}
}
return {
id,
kind: spec.kind,
label,
bins,
};
});
}
function buildSkillStatus(entry: SkillEntry, config?: ClawdisConfig): SkillStatusEntry {
const skillKey = resolveSkillKey(entry);
const skillConfig = resolveSkillConfig(config, skillKey);
const disabled = skillConfig?.enabled === false;
const always = entry.clawdis?.always === true;
const requiredBins = entry.clawdis?.requires?.bins ?? [];
const requiredEnv = entry.clawdis?.requires?.env ?? [];
const requiredConfig = entry.clawdis?.requires?.config ?? [];
const missingBins = requiredBins.filter((bin) => !hasBinary(bin));
const missingEnv: string[] = [];
for (const envName of requiredEnv) {
if (process.env[envName]) continue;
if (skillConfig?.env?.[envName]) continue;
if (skillConfig?.apiKey && entry.clawdis?.primaryEnv === envName) {
continue;
}
missingEnv.push(envName);
}
const configChecks: SkillStatusConfigCheck[] = requiredConfig.map((pathStr) => {
const value = resolveConfigPath(config, pathStr);
const satisfied = isConfigPathTruthy(config, pathStr);
return { path: pathStr, value, satisfied };
});
const missingConfig = configChecks
.filter((check) => !check.satisfied)
.map((check) => check.path);
const missing = always
? { bins: [], env: [], config: [] }
: { bins: missingBins, env: missingEnv, config: missingConfig };
const eligible =
!disabled &&
(always ||
(missing.bins.length === 0 &&
missing.env.length === 0 &&
missing.config.length === 0));
return {
name: entry.skill.name,
description: entry.skill.description,
source: entry.skill.source,
filePath: entry.skill.filePath,
baseDir: entry.skill.baseDir,
skillKey,
primaryEnv: entry.clawdis?.primaryEnv,
always,
disabled,
eligible,
requirements: {
bins: requiredBins,
env: requiredEnv,
config: requiredConfig,
},
missing,
configChecks,
install: normalizeInstallOptions(entry),
};
}
export function buildWorkspaceSkillStatus(
workspaceDir: string,
opts?: {
config?: ClawdisConfig;
managedSkillsDir?: string;
entries?: SkillEntry[];
},
): SkillStatusReport {
const managedSkillsDir =
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
const skillEntries =
opts?.entries ?? loadWorkspaceSkillEntries(workspaceDir, opts);
return {
workspaceDir,
managedSkillsDir,
skills: skillEntries.map((entry) => buildSkillStatus(entry, opts?.config)),
};
}

View File

@@ -11,6 +11,7 @@ import {
buildWorkspaceSkillsPrompt,
loadWorkspaceSkillEntries,
} from "./skills.js";
import { buildWorkspaceSkillStatus } from "./skills-status.js";
async function writeSkill(params: {
dir: string;
@@ -295,6 +296,34 @@ describe("buildWorkspaceSkillSnapshot", () => {
});
});
describe("buildWorkspaceSkillStatus", () => {
it("reports missing requirements and install options", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
const skillDir = path.join(workspaceDir, "skills", "status-skill");
await writeSkill({
dir: skillDir,
name: "status-skill",
description: "Needs setup",
metadata:
'{"clawdis":{"requires":{"bins":["fakebin"],"env":["ENV_KEY"],"config":["browser.enabled"]},"install":[{"id":"brew","kind":"brew","formula":"fakebin","bins":["fakebin"],"label":"Install fakebin"}]}}',
});
const report = buildWorkspaceSkillStatus(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
config: { browser: { enabled: false } },
});
const skill = report.skills.find((entry) => entry.name === "status-skill");
expect(skill).toBeDefined();
expect(skill?.eligible).toBe(false);
expect(skill?.missing.bins).toContain("fakebin");
expect(skill?.missing.env).toContain("ENV_KEY");
expect(skill?.missing.config).toContain("browser.enabled");
expect(skill?.install[0]?.id).toBe("brew");
});
});
describe("applySkillEnvOverrides", () => {
it("sets and restores env vars", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));

View File

@@ -76,7 +76,6 @@ function resolveBundledSkillsDir(): string | undefined {
return undefined;
}
function getFrontmatterValue(
frontmatter: ParsedSkillFrontmatter,
key: string,
@@ -180,7 +179,10 @@ const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
"browser.enabled": true,
};
function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
export function resolveConfigPath(
config: ClawdisConfig | undefined,
pathStr: string,
) {
const parts = pathStr.split(".").filter(Boolean);
let current: unknown = config;
for (const part of parts) {
@@ -190,7 +192,7 @@ function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
return current;
}
function isConfigPathTruthy(
export function isConfigPathTruthy(
config: ClawdisConfig | undefined,
pathStr: string,
): boolean {
@@ -201,7 +203,7 @@ function isConfigPathTruthy(
return isTruthy(value);
}
function resolveSkillConfig(
export function resolveSkillConfig(
config: ClawdisConfig | undefined,
skillKey: string,
): SkillConfig | undefined {
@@ -212,7 +214,7 @@ function resolveSkillConfig(
return entry;
}
function hasBinary(bin: string): boolean {
export function hasBinary(bin: string): boolean {
const pathEnv = process.env.PATH ?? "";
const parts = pathEnv.split(path.delimiter).filter(Boolean);
for (const part of parts) {
@@ -277,6 +279,7 @@ function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
return entry?.clawdis?.skillKey ?? skill.name;
}
function shouldIncludeSkill(params: {
entry: SkillEntry;
config?: ClawdisConfig;
@@ -326,6 +329,7 @@ function filterSkillEntries(
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
}
export function applySkillEnvOverrides(params: {
skills: SkillEntry[];
config?: ClawdisConfig;
@@ -435,11 +439,11 @@ function loadSkillEntries(
const managedSkills = loadSkillsFromDir({
dir: managedSkillsDir,
source: "clawdis-managed",
});
}).skills;
const workspaceSkills = loadSkillsFromDir({
dir: workspaceSkillsDir,
source: "clawdis-workspace",
});
}).skills;
const merged = new Map<string, Skill>();
// Precedence: extra < bundled < managed < workspace