Read skills from the global and workspace .agents/skills directories
- Discover skill directories under ~/.agents/skills and each workspace''s .agents/skills; a directory qualifies with a full skill.manifest.json or just a SKILL.md, whose manifest is derived (frontmatter name or the directory name). Paths resolve per platform on Windows, macOS, and Linux. - Sync at startup for every organization and again when the Skills page opens; skills the org already carries are skipped, and one broken directory cannot block the rest. - AGENTCOMPANY_NEW_SKILLS_ROOT points tests at a temporary library (overriding USERPROFILE crashes Electron on Windows).
This commit is contained in:
1 parent
256e0680c6
commit
2d43950123
14 files changed
+303
-12
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SkillService } from "../../src/main/services/skill-service";
|
||||
import { syncSkillsFromDisk } from "../../src/main/services/skill-discovery";
|
||||
|
||||
interface Harness {
|
||||
connection: DatabaseConnection;
|
||||
skills: SkillService;
|
||||
orgId: string;
|
||||
globalRoot: string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-skdisc-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const workspaceRoot = join(tmpdir(), `ac-skdisc-ws-${randomUUID()}`);
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
let orgId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
organization.createWorkspace({ orgId, name: "HQ", localPath: workspaceRoot, projectId: null });
|
||||
});
|
||||
const globalRoot = join(tmpdir(), `ac-skdisc-global-${randomUUID()}`);
|
||||
mkdirSync(globalRoot, { recursive: true });
|
||||
const artifactRoot = join(tmpdir(), `ac-skdisc-art-${randomUUID()}`);
|
||||
return { connection, skills: new SkillService(connection.db, artifactRoot), orgId, globalRoot, workspaceRoot };
|
||||
}
|
||||
|
||||
function writeManifestSkill(root: string, dirName: string, name: string): void {
|
||||
const dir = join(root, dirName);
|
||||
mkdirSync(join(dir, "checks"), { recursive: true });
|
||||
writeFileSync(join(dir, "SKILL.md"), `Guide for ${name}.\n`, "utf8");
|
||||
writeFileSync(join(dir, "checks", "acceptance.txt"), "accepted\n", "utf8");
|
||||
writeFileSync(join(dir, "skill.manifest.json"), JSON.stringify({
|
||||
name,
|
||||
version: "1.0.0",
|
||||
entrypoint: "SKILL.md",
|
||||
capabilities: ["review"],
|
||||
required_tools: ["git"],
|
||||
compatible_models: ["gpt-5.4"],
|
||||
risk_level: "low",
|
||||
tests: ["checks/acceptance.txt"],
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
}, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function writeMarkdownOnlySkill(root: string, dirName: string, frontmatterName: string | null): void {
|
||||
const dir = join(root, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const frontmatter = frontmatterName ? `---\nname: ${frontmatterName}\ndescription: does things\n---\n\n` : "";
|
||||
writeFileSync(join(dir, "SKILL.md"), `${frontmatter}# Steps\nDo the thing.\n`, "utf8");
|
||||
}
|
||||
|
||||
function skillNames(h: Harness): string[] {
|
||||
return (h.connection.db.prepare("select name from skill_definitions where org_id = ? order by name asc").all(h.orgId) as Array<{ name: string }>)
|
||||
.map((row) => row.name);
|
||||
}
|
||||
|
||||
describe("skill discovery", () => {
|
||||
test("imports skills from the global directory and every workspace", () => {
|
||||
const h = harness();
|
||||
writeManifestSkill(h.globalRoot, "release-gate", "Release Gate");
|
||||
writeMarkdownOnlySkill(join(h.workspaceRoot, ".agents", "skills"), "code-style", "Code Style");
|
||||
|
||||
const report = syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot);
|
||||
expect(report.imported).toBe(2);
|
||||
expect(report.failures).toEqual([]);
|
||||
expect(skillNames(h)).toEqual(["Code Style", "Release Gate"]);
|
||||
});
|
||||
|
||||
test("a SKILL.md without frontmatter falls back to the directory name", () => {
|
||||
const h = harness();
|
||||
writeMarkdownOnlySkill(h.globalRoot, "incident-runbook", null);
|
||||
syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot);
|
||||
expect(skillNames(h)).toEqual(["incident-runbook"]);
|
||||
});
|
||||
|
||||
test("re-syncing skips what the org already carries", () => {
|
||||
const h = harness();
|
||||
writeManifestSkill(h.globalRoot, "release-gate", "Release Gate");
|
||||
syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot);
|
||||
const second = syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot);
|
||||
expect(second.imported).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(skillNames(h)).toEqual(["Release Gate"]);
|
||||
});
|
||||
|
||||
test("one broken directory cannot block the rest", () => {
|
||||
const h = harness();
|
||||
writeManifestSkill(h.globalRoot, "good", "Good Skill");
|
||||
const broken = join(h.globalRoot, "broken");
|
||||
mkdirSync(broken, { recursive: true });
|
||||
writeFileSync(join(broken, "skill.manifest.json"), "{ not json", "utf8");
|
||||
writeFileSync(join(broken, "SKILL.md"), "x", "utf8");
|
||||
|
||||
const report = syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot);
|
||||
expect(report.imported).toBe(1);
|
||||
expect(report.failures).toHaveLength(1);
|
||||
expect(skillNames(h)).toEqual(["Good Skill"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user