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

View File

@@ -13,6 +13,12 @@ import {
ConfigGetParamsSchema,
type ConfigSetParams,
ConfigSetParamsSchema,
type SkillsInstallParams,
SkillsInstallParamsSchema,
type SkillsStatusParams,
SkillsStatusParamsSchema,
type SkillsUpdateParams,
SkillsUpdateParamsSchema,
type ConnectParams,
ConnectParamsSchema,
type CronAddParams,
@@ -135,6 +141,15 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema,
);
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(
SkillsStatusParamsSchema,
);
export const validateSkillsInstallParams = ajv.compile<SkillsInstallParams>(
SkillsInstallParamsSchema,
);
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(
SkillsUpdateParamsSchema,
);
export const validateCronListParams =
ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
@@ -193,6 +208,9 @@ export {
SessionsPatchParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
SkillsStatusParamsSchema,
SkillsInstallParamsSchema,
SkillsUpdateParamsSchema,
CronJobSchema,
CronListParamsSchema,
CronStatusParamsSchema,
@@ -232,6 +250,9 @@ export type {
NodePairApproveParams,
ConfigGetParams,
ConfigSetParams,
SkillsStatusParams,
SkillsInstallParams,
SkillsUpdateParams,
NodePairRejectParams,
NodePairVerifyParams,
NodeListParams,

View File

@@ -305,6 +305,30 @@ export const ConfigSetParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const SkillsStatusParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const SkillsInstallParamsSchema = Type.Object(
{
name: NonEmptyString,
installId: NonEmptyString,
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
},
{ additionalProperties: false },
);
export const SkillsUpdateParamsSchema = Type.Object(
{
skillKey: NonEmptyString,
enabled: Type.Optional(Type.Boolean()),
apiKey: Type.Optional(Type.String()),
env: Type.Optional(Type.Record(NonEmptyString, Type.String())),
},
{ additionalProperties: false },
);
export const CronScheduleSchema = Type.Union([
Type.Object(
{
@@ -557,6 +581,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsPatchParams: SessionsPatchParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
SkillsStatusParams: SkillsStatusParamsSchema,
SkillsInstallParams: SkillsInstallParamsSchema,
SkillsUpdateParams: SkillsUpdateParamsSchema,
CronJob: CronJobSchema,
CronListParams: CronListParamsSchema,
CronStatusParams: CronStatusParamsSchema,
@@ -600,6 +627,9 @@ export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type SkillsStatusParams = Static<typeof SkillsStatusParamsSchema>;
export type SkillsInstallParams = Static<typeof SkillsInstallParamsSchema>;
export type SkillsUpdateParams = Static<typeof SkillsUpdateParamsSchema>;
export type CronJob = Static<typeof CronJobSchema>;
export type CronListParams = Static<typeof CronListParamsSchema>;
export type CronStatusParams = Static<typeof CronStatusParamsSchema>;

View File

@@ -11,6 +11,9 @@ import chalk from "chalk";
import { type WebSocket, WebSocketServer } from "ws";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { installSkill } from "../agents/skills-install.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
import {
normalizeThinkLevel,
normalizeVerboseLevel,
@@ -90,7 +93,7 @@ import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
import { defaultRuntime } from "../runtime.js";
import { monitorTelegramProvider } from "../telegram/monitor.js";
import { sendMessageTelegram } from "../telegram/send.js";
import { normalizeE164 } from "../utils.js";
import { normalizeE164, resolveUserPath } from "../utils.js";
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
@@ -150,6 +153,9 @@ import {
validateSendParams,
validateSessionsListParams,
validateSessionsPatchParams,
validateSkillsInstallParams,
validateSkillsStatusParams,
validateSkillsUpdateParams,
validateWakeParams,
} from "./protocol/index.js";
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
@@ -210,6 +216,9 @@ const METHODS = [
"status",
"config.get",
"config.set",
"skills.status",
"skills.install",
"skills.update",
"voicewake.get",
"voicewake.set",
"sessions.list",
@@ -3063,6 +3072,119 @@ export async function startGatewayServer(
);
break;
}
case "skills.status": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSkillsStatusParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid skills.status params: ${formatValidationErrors(validateSkillsStatusParams.errors)}`,
),
);
break;
}
const cfg = loadConfig();
const workspaceDirRaw =
cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspaceDir = resolveUserPath(workspaceDirRaw);
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
});
respond(true, report, undefined);
break;
}
case "skills.install": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSkillsInstallParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid skills.install params: ${formatValidationErrors(validateSkillsInstallParams.errors)}`,
),
);
break;
}
const p = params as {
name: string;
installId: string;
timeoutMs?: number;
};
const cfg = loadConfig();
const workspaceDirRaw =
cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const result = await installSkill({
workspaceDir: workspaceDirRaw,
skillName: p.name,
installId: p.installId,
timeoutMs: p.timeoutMs,
});
respond(
result.ok,
result,
result.ok
? undefined
: errorShape(ErrorCodes.UNAVAILABLE, result.message),
);
break;
}
case "skills.update": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSkillsUpdateParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid skills.update params: ${formatValidationErrors(validateSkillsUpdateParams.errors)}`,
),
);
break;
}
const p = params as {
skillKey: string;
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
};
const cfg = loadConfig();
const skills = { ...(cfg.skills ?? {}) };
const current = { ...(skills[p.skillKey] ?? {}) };
if (typeof p.enabled === "boolean") {
current.enabled = p.enabled;
}
if (typeof p.apiKey === "string") {
const trimmed = p.apiKey.trim();
if (trimmed) current.apiKey = trimmed;
else delete current.apiKey;
}
if (p.env && typeof p.env === "object") {
const nextEnv = { ...(current.env ?? {}) };
for (const [key, value] of Object.entries(p.env)) {
const trimmedKey = key.trim();
if (!trimmedKey) continue;
const trimmedVal = String(value ?? "").trim();
if (!trimmedVal) delete nextEnv[trimmedKey];
else nextEnv[trimmedKey] = trimmedVal;
}
current.env = nextEnv;
}
skills[p.skillKey] = current;
const nextConfig: ClawdisConfig = {
...cfg,
skills,
};
await writeConfigFile(nextConfig);
respond(
true,
{ ok: true, skillKey: p.skillKey, config: current },
undefined,
);
break;
}
case "sessions.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSessionsListParams(params)) {