feat: add managed skills gating

This commit is contained in:
Peter Steinberger
2025-12-20 12:22:15 +01:00
parent cf21a15e06
commit d1850aaada
36 changed files with 1235 additions and 16 deletions

View File

@@ -24,6 +24,7 @@ import {
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import type { ClawdisConfig } from "../config/config.js";
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import {
createToolDebouncer,
@@ -43,7 +44,13 @@ import {
createClawdisCodingTools,
sanitizeContentBlocksImages,
} from "./pi-tools.js";
import { buildWorkspaceSkillsPrompt } from "./skills.js";
import {
applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot,
buildWorkspaceSkillSnapshot,
type SkillSnapshot,
loadWorkspaceSkillEntries,
} from "./skills.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
@@ -200,6 +207,8 @@ export async function runEmbeddedPiAgent(params: {
sessionId: string;
sessionFile: string;
workspaceDir: string;
config?: ClawdisConfig;
skillsSnapshot?: SkillSnapshot;
prompt: string;
provider?: string;
model?: string;
@@ -244,8 +253,30 @@ export async function runEmbeddedPiAgent(params: {
thinkingLevel,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(resolvedWorkspace);
try {
const skillEntries = params.skillsSnapshot
? undefined
: loadWorkspaceSkillEntries(resolvedWorkspace, {
config: params.config,
});
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
config: params.config,
entries: skillEntries,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
const systemPrompt = buildAgentSystemPrompt({
@@ -258,8 +289,7 @@ export async function runEmbeddedPiAgent(params: {
})),
defaultThinkLevel: params.thinkLevel,
});
const systemPromptWithSkills =
systemPrompt + buildWorkspaceSkillsPrompt(resolvedWorkspace);
const systemPromptWithSkills = systemPrompt + skillsSnapshot.prompt;
const sessionManager = new SessionManager(false, params.sessionFile);
const settingsManager = new SettingsManager();
@@ -576,6 +606,7 @@ export async function runEmbeddedPiAgent(params: {
},
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
}
});

View File

@@ -24,9 +24,42 @@ description: Does demo things
"utf-8",
);
const prompt = buildWorkspaceSkillsPrompt(workspaceDir);
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
});
expect(prompt).toContain("demo-skill");
expect(prompt).toContain("Does demo things");
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
});
it("filters skills based on env/config gates", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---
name: nano-banana-pro
description: Generates images
metadata: {"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}}
---
# Nano Banana
`,
"utf-8",
);
const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
config: { skills: { "nano-banana-pro": { apiKey: "" } } },
});
expect(missingPrompt).not.toContain("nano-banana-pro");
const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } },
});
expect(enabledPrompt).toContain("nano-banana-pro");
});
});

View File

@@ -1,15 +1,395 @@
import fs from "node:fs";
import path from "node:path";
import {
type Skill,
type SkillFrontmatter,
formatSkillsForPrompt,
loadSkillsFromDir,
} from "@mariozechner/pi-coding-agent";
export function buildWorkspaceSkillsPrompt(workspaceDir: string): string {
const skillsDir = path.join(workspaceDir, "skills");
const skills = loadSkillsFromDir({
dir: skillsDir,
source: "clawdis-workspace",
});
return formatSkillsForPrompt(skills);
import type { ClawdisConfig, SkillConfig } from "../config/config.js";
import { CONFIG_DIR } from "../utils.js";
type ClawdisSkillMetadata = {
always?: boolean;
skillKey?: string;
primaryEnv?: string;
requires?: {
bins?: string[];
env?: string[];
config?: string[];
};
};
type SkillEntry = {
skill: Skill;
frontmatter: SkillFrontmatter;
clawdis?: ClawdisSkillMetadata;
};
export type SkillSnapshot = {
prompt: string;
skills: Array<{ name: string; primaryEnv?: string }>;
};
function getFrontmatterValue(
frontmatter: SkillFrontmatter,
key: string,
): string | undefined {
const raw = frontmatter[key];
return typeof raw === "string" ? raw : undefined;
}
function stripQuotes(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
function parseFrontmatter(content: string): SkillFrontmatter {
const frontmatter: SkillFrontmatter = {};
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return frontmatter;
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return frontmatter;
const block = normalized.slice(4, endIndex);
for (const line of block.split("\n")) {
const match = line.match(/^([\w-]+):\s*(.*)$/);
if (!match) continue;
const key = match[1];
const value = stripQuotes(match[2].trim());
if (!key || !value) continue;
frontmatter[key] = value;
}
return frontmatter;
}
function normalizeStringList(input: unknown): string[] {
if (!input) return [];
if (Array.isArray(input)) {
return input.map((value) => String(value).trim()).filter(Boolean);
}
if (typeof input === "string") {
return input
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
return [];
}
function isTruthy(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") return value.trim().length > 0;
return true;
}
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
"browser.enabled": true,
};
function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
const parts = pathStr.split(".").filter(Boolean);
let current: unknown = config;
for (const part of parts) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
function isConfigPathTruthy(
config: ClawdisConfig | undefined,
pathStr: string,
): boolean {
const value = resolveConfigPath(config, pathStr);
if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) {
return DEFAULT_CONFIG_VALUES[pathStr] === true;
}
return isTruthy(value);
}
function resolveSkillConfig(
config: ClawdisConfig | undefined,
skillKey: string,
): SkillConfig | undefined {
const skills = config?.skills;
if (!skills || typeof skills !== "object") return undefined;
const entry = (skills as Record<string, SkillConfig | undefined>)[skillKey];
if (!entry || typeof entry !== "object") return undefined;
return entry;
}
function hasBinary(bin: string): boolean {
const pathEnv = process.env.PATH ?? "";
const parts = pathEnv.split(path.delimiter).filter(Boolean);
for (const part of parts) {
const candidate = path.join(part, bin);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return true;
} catch {
// keep scanning
}
}
return false;
}
function resolveClawdisMetadata(
frontmatter: SkillFrontmatter,
): ClawdisSkillMetadata | undefined {
const raw = getFrontmatterValue(frontmatter, "metadata");
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw) as { clawdis?: unknown };
if (!parsed || typeof parsed !== "object") return undefined;
const clawdis = (parsed as { clawdis?: unknown }).clawdis;
if (!clawdis || typeof clawdis !== "object") return undefined;
const clawdisObj = clawdis as Record<string, unknown>;
const requiresRaw =
typeof clawdisObj.requires === "object" && clawdisObj.requires !== null
? (clawdisObj.requires as Record<string, unknown>)
: undefined;
return {
always:
typeof clawdisObj.always === "boolean"
? clawdisObj.always
: undefined,
skillKey:
typeof clawdisObj.skillKey === "string"
? clawdisObj.skillKey
: undefined,
primaryEnv:
typeof clawdisObj.primaryEnv === "string"
? clawdisObj.primaryEnv
: undefined,
requires: requiresRaw
? {
bins: normalizeStringList(requiresRaw.bins),
env: normalizeStringList(requiresRaw.env),
config: normalizeStringList(requiresRaw.config),
}
: undefined,
};
} catch {
return undefined;
}
}
function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
return entry?.clawdis?.skillKey ?? skill.name;
}
function shouldIncludeSkill(params: {
entry: SkillEntry;
config?: ClawdisConfig;
}): boolean {
const { entry, config } = params;
const skillKey = resolveSkillKey(entry.skill, entry);
const skillConfig = resolveSkillConfig(config, skillKey);
if (skillConfig?.enabled === false) return false;
if (entry.clawdis?.always === true) {
return true;
}
const requiredBins = entry.clawdis?.requires?.bins ?? [];
if (requiredBins.length > 0) {
for (const bin of requiredBins) {
if (!hasBinary(bin)) return false;
}
}
const requiredEnv = entry.clawdis?.requires?.env ?? [];
if (requiredEnv.length > 0) {
for (const envName of requiredEnv) {
if (process.env[envName]) continue;
if (skillConfig?.env?.[envName]) continue;
if (
skillConfig?.apiKey &&
entry.clawdis?.primaryEnv === envName
) {
continue;
}
return false;
}
}
const requiredConfig = entry.clawdis?.requires?.config ?? [];
if (requiredConfig.length > 0) {
for (const configPath of requiredConfig) {
if (!isConfigPathTruthy(config, configPath)) return false;
}
}
return true;
}
function filterSkillEntries(
entries: SkillEntry[],
config?: ClawdisConfig,
): SkillEntry[] {
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
}
export function applySkillEnvOverrides(params: {
skills: SkillEntry[];
config?: ClawdisConfig;
}) {
const { skills, config } = params;
const updates: Array<{ key: string; prev: string | undefined }> = [];
for (const entry of skills) {
const skillKey = resolveSkillKey(entry.skill, entry);
const skillConfig = resolveSkillConfig(config, skillKey);
if (!skillConfig) continue;
if (skillConfig.env) {
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
if (!envValue || process.env[envKey]) continue;
updates.push({ key: envKey, prev: process.env[envKey] });
process.env[envKey] = envValue;
}
}
const primaryEnv = entry.clawdis?.primaryEnv;
if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) {
updates.push({ key: primaryEnv, prev: process.env[primaryEnv] });
process.env[primaryEnv] = skillConfig.apiKey;
}
}
return () => {
for (const update of updates) {
if (update.prev === undefined) delete process.env[update.key];
else process.env[update.key] = update.prev;
}
};
}
export function applySkillEnvOverridesFromSnapshot(params: {
snapshot?: SkillSnapshot;
config?: ClawdisConfig;
}) {
const { snapshot, config } = params;
if (!snapshot) return () => {};
const updates: Array<{ key: string; prev: string | undefined }> = [];
for (const skill of snapshot.skills) {
const skillConfig = resolveSkillConfig(config, skill.name);
if (!skillConfig) continue;
if (skillConfig.env) {
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
if (!envValue || process.env[envKey]) continue;
updates.push({ key: envKey, prev: process.env[envKey] });
process.env[envKey] = envValue;
}
}
if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) {
updates.push({ key: skill.primaryEnv, prev: process.env[skill.primaryEnv] });
process.env[skill.primaryEnv] = skillConfig.apiKey;
}
}
return () => {
for (const update of updates) {
if (update.prev === undefined) delete process.env[update.key];
else process.env[update.key] = update.prev;
}
};
}
function loadSkillEntries(
workspaceDir: string,
opts?: {
config?: ClawdisConfig;
managedSkillsDir?: string;
},
): SkillEntry[] {
const managedSkillsDir =
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
const workspaceSkillsDir = path.join(workspaceDir, "skills");
const managedSkills = loadSkillsFromDir({
dir: managedSkillsDir,
source: "clawdis-managed",
}).skills;
const workspaceSkills = loadSkillsFromDir({
dir: workspaceSkillsDir,
source: "clawdis-workspace",
}).skills;
const merged = new Map<string, Skill>();
for (const skill of managedSkills) merged.set(skill.name, skill);
for (const skill of workspaceSkills) merged.set(skill.name, skill);
const skillEntries: SkillEntry[] = Array.from(merged.values()).map((skill) => {
let frontmatter: SkillFrontmatter = {};
try {
const raw = fs.readFileSync(skill.filePath, "utf-8");
frontmatter = parseFrontmatter(raw);
} catch {
// ignore malformed skills
}
return { skill, frontmatter, clawdis: resolveClawdisMetadata(frontmatter) };
});
return skillEntries;
}
export function buildWorkspaceSkillSnapshot(
workspaceDir: string,
opts?: {
config?: ClawdisConfig;
managedSkillsDir?: string;
entries?: SkillEntry[];
},
): SkillSnapshot {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(skillEntries, opts?.config);
return {
prompt: formatSkillsForPrompt(eligible.map((entry) => entry.skill)),
skills: eligible.map((entry) => ({
name: entry.skill.name,
primaryEnv: entry.clawdis?.primaryEnv,
})),
};
}
export function buildWorkspaceSkillsPrompt(
workspaceDir: string,
opts?: {
config?: ClawdisConfig;
managedSkillsDir?: string;
entries?: SkillEntry[];
},
): string {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(skillEntries, opts?.config);
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
}
export function loadWorkspaceSkillEntries(
workspaceDir: string,
opts?: {
config?: ClawdisConfig;
managedSkillsDir?: string;
},
): SkillEntry[] {
return loadSkillEntries(workspaceDir, opts);
}
export function filterWorkspaceSkillEntries(
entries: SkillEntry[],
config?: ClawdisConfig,
): SkillEntry[] {
return filterSkillEntries(entries, config);
}

View File

@@ -11,6 +11,7 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import { type ClawdisConfig, loadConfig } from "../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
@@ -659,17 +660,48 @@ export async function getReplyFromConfig(
sessionId: sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
};
const skillSnapshot =
isFirstTurnInSession || !current.skillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
: current.skillsSnapshot;
sessionEntry = {
...current,
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
systemSent: true,
skillsSnapshot: skillSnapshot,
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
systemSent = true;
}
const skillsSnapshot =
sessionEntry?.skillsSnapshot ??
(isFirstTurnInSession
? undefined
: buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }));
if (
skillsSnapshot &&
sessionStore &&
sessionKey &&
!isFirstTurnInSession &&
!sessionEntry?.skillsSnapshot
) {
const current = sessionEntry ?? {
sessionId: sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
};
sessionEntry = {
...current,
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
skillsSnapshot,
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
const prefixedBody = transcribedText
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
.filter(Boolean)
@@ -709,6 +741,8 @@ export async function getReplyFromConfig(
sessionId: sessionIdFinal,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
prompt: commandBody,
provider,
model,

View File

@@ -10,6 +10,7 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import { chunkText } from "../auto-reply/chunk.js";
import type { MsgContext } from "../auto-reply/templating.js";
import {
@@ -205,10 +206,31 @@ export async function agentCommand(
persistedVerbose ??
(agentCfg?.verboseDefault as VerboseLevel | undefined);
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
: sessionEntry?.skillsSnapshot;
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {
const current = sessionEntry ?? {
sessionId,
updatedAt: Date.now(),
};
const next: SessionEntry = {
...current,
sessionId,
updatedAt: Date.now(),
skillsSnapshot,
};
sessionStore[sessionKey] = next;
await saveSessionStore(storePath, sessionStore);
sessionEntry = next;
}
// Persist explicit /command overrides to the session store when we have a key.
if (sessionStore && sessionKey) {
const entry = sessionEntry ??
sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() };
const entry =
sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: Date.now() };
const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() };
if (thinkOverride) {
if (thinkOverride === "off") delete next.thinkingLevel;
@@ -245,6 +267,8 @@ export async function agentCommand(
sessionId,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
prompt: body,
provider,
model,

View File

@@ -120,6 +120,13 @@ export type GatewayConfig = {
controlUi?: GatewayControlUiConfig;
};
export type SkillConfig = {
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
[key: string]: unknown;
};
export type ClawdisConfig = {
identity?: {
name?: string;
@@ -168,6 +175,7 @@ export type ClawdisConfig = {
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
gateway?: GatewayConfig;
skills?: Record<string, SkillConfig>;
};
// New branding path (preferred)
@@ -349,6 +357,17 @@ const ClawdisSchema = z.object({
.optional(),
})
.optional(),
skills: z
.record(
z
.object({
enabled: z.boolean().optional(),
apiKey: z.string().optional(),
env: z.record(z.string()).optional(),
})
.passthrough(),
)
.optional(),
});
export type ConfigValidationIssue = {

View File

@@ -25,6 +25,12 @@ export type SessionEntry = {
lastTo?: string;
// Optional flag to mirror Mac app UI and future sync states.
syncing?: boolean | string;
skillsSnapshot?: SessionSkillSnapshot;
};
export type SessionSkillSnapshot = {
prompt: string;
skills: Array<{ name: string; primaryEnv?: string }>;
};
export function resolveSessionTranscriptsDir(): string {
@@ -125,6 +131,7 @@ export async function updateLastRoute(params: {
model: existing?.model,
contextTokens: existing?.contextTokens,
syncing: existing?.syncing,
skillsSnapshot: existing?.skillsSnapshot,
lastChannel: channel,
lastTo: to?.trim() ? to.trim() : undefined,
};

View File

@@ -10,6 +10,7 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import { chunkText } from "../auto-reply/chunk.js";
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
import type { CliDeps } from "../cli/deps.js";
@@ -204,6 +205,21 @@ export async function runCronIsolatedAgentTurn(params: {
const commandBody = base;
const needsSkillsSnapshot =
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg })
: cronSession.sessionEntry.skillsSnapshot;
if (needsSkillsSnapshot && skillsSnapshot) {
cronSession.sessionEntry = {
...cronSession.sessionEntry,
updatedAt: Date.now(),
skillsSnapshot,
};
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
await saveSessionStore(cronSession.storePath, cronSession.store);
}
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
if (isFirstTurnInSession) {
cronSession.sessionEntry.systemSent = true;
@@ -223,6 +239,8 @@ export async function runCronIsolatedAgentTurn(params: {
sessionId: cronSession.sessionEntry.sessionId,
sessionFile,
workspaceDir,
config: params.cfg,
skillsSnapshot,
prompt: commandBody,
provider,
model,

View File

@@ -3857,6 +3857,7 @@ export async function startGatewayServer(
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
systemSent: entry?.systemSent,
skillsSnapshot: entry?.skillsSnapshot,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
};