refactor(agent): add result-based execution env

This commit is contained in:
Mario Zechner
2026-05-14 18:26:29 +02:00
parent 9fcf924ac9
commit 846906e4d1
13 changed files with 783 additions and 383 deletions
+70 -79
View File
@@ -1,6 +1,6 @@
import ignore from "ignore";
import { parse } from "yaml";
import type { ExecutionEnv, Skill } from "./types.js";
import { type ExecutionEnv, type FileInfo, getOrUndefined, type Result, type Skill } from "./types.js";
const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024;
@@ -44,7 +44,7 @@ export async function loadSkills(
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
const rootInfo = await safeFileInfo(env, dir);
const rootInfo = getOrUndefined(await env.fileInfo(dir));
if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue;
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
skills.push(...result.skills);
@@ -89,18 +89,13 @@ async function loadSkillsFromDirInternal(
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
if (!(await env.exists(dir))) return { skills, diagnostics };
const dirInfo = await safeFileInfo(env, dir);
const dirInfo = getOrUndefined(await env.fileInfo(dir));
if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics };
await addIgnoreRules(env, ignoreMatcher, dir, rootDir);
let entries: Awaited<ReturnType<ExecutionEnv["listDir"]>>;
try {
entries = await env.listDir(dir);
} catch {
return { skills, diagnostics };
}
const entries = getOrUndefined(await env.listDir(dir));
if (!entries) return { skills, diagnostics };
for (const entry of entries) {
if (entry.name !== "SKILL.md") continue;
@@ -148,16 +143,15 @@ async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string,
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = joinEnvPath(dir, filename);
const info = await safeFileInfo(env, ignorePath);
const info = getOrUndefined(await env.fileInfo(ignorePath));
if (info?.kind !== "file") continue;
try {
const content = await env.readTextFile(ignorePath);
const patterns = content
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns);
} catch {}
const content = await env.readTextFile(ignorePath);
if (!content.ok) continue;
const patterns = content.value
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns);
}
}
@@ -184,40 +178,47 @@ async function loadSkillFromFile(
filePath: string,
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
const diagnostics: SkillDiagnostic[] = [];
try {
const rawContent = await env.readTextFile(filePath);
const { frontmatter, body } = parseFrontmatter<SkillFrontmatter>(rawContent);
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
for (const error of validateDescription(frontmatter.description)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
const name = frontmatter.name || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
if (!frontmatter.description || frontmatter.description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description: frontmatter.description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
} catch (error) {
const message = error instanceof Error ? error.message : "failed to parse skill file";
diagnostics.push({ type: "warning", message, path: filePath });
const rawContent = await env.readTextFile(filePath);
if (!rawContent.ok) {
diagnostics.push({ type: "warning", message: rawContent.error.message, path: filePath });
return { skill: null, diagnostics };
}
const parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value);
if (!parsed.ok) {
diagnostics.push({ type: "warning", message: parsed.error.message, path: filePath });
return { skill: null, diagnostics };
}
const { frontmatter, body } = parsed.value;
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
for (const error of validateDescription(description)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined;
const name = frontmatterName || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
if (!description || description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
}
function validateName(name: string, parentDirName: string): string[] {
@@ -242,39 +243,29 @@ function validateDescription(description: string | undefined): string[] {
return errors;
}
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
}
async function safeFileInfo(
env: ExecutionEnv,
path: string,
): Promise<Awaited<ReturnType<ExecutionEnv["fileInfo"]>> | undefined> {
function parseFrontmatter<T extends Record<string, unknown>>(
content: string,
): Result<{ frontmatter: T; body: string }, Error> {
try {
return await env.fileInfo(path);
} catch {
return undefined;
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
}
}
async function resolveKind(
env: ExecutionEnv,
info: Awaited<ReturnType<ExecutionEnv["fileInfo"]>>,
): Promise<"file" | "directory" | undefined> {
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind;
try {
const realPath = await env.realPath(info.path);
const target = await env.fileInfo(realPath);
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
} catch {
return undefined;
}
const realPath = await env.realPath(info.path);
if (!realPath.ok) return undefined;
const target = getOrUndefined(await env.fileInfo(realPath.value));
if (!target) return undefined;
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
}
function joinEnvPath(base: string, child: string): string {