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:
ZacharyZhang-NY committed 2026-07-07 01:21:33 -04:00
1 parent 256e0680c6
commit 2d43950123
14 files changed
+303 -12

No files matched your search

+5
View File
@@ -29,6 +29,7 @@ import { MeetingService } from "../services/meeting-service";
import { SprintService } from "../services/sprint-service";
import { ComposioConnectionService } from "../services/composio/composio-connection-service";
import { ConnectorGrantsService } from "../services/composio/connector-grants";
import { syncSkillsFromDisk } from "../services/skill-discovery";
import { coalesce } from "./coalesce";
import { normalizeExternalBrowserUrl } from "./external-url";
@@ -252,6 +253,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
});
ipcMain.handle(IPC_CHANNELS.importSkillDirectory, (_event, input) => mutate(() => skills.importDirectory(input)));
ipcMain.handle(IPC_CHANNELS.syncSkillsFromDisk, (_event, input) => mutate(() => {
syncSkillsFromDisk(deps.connection.db, skills, input.orgId);
return null;
}));
ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input)));
ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input)));
ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input)));
+22
View File
@@ -1,9 +1,12 @@
import { app } from "electron";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { DatabaseSync } from "node:sqlite";
import { DatabaseConnection } from "./database/connection";
import { createMainWindow } from "./app/create-window";
import { registerIpcHandlers } from "./app/ipc-handlers";
import { syncSkillsFromDisk } from "./services/skill-discovery";
import { SkillService } from "./services/skill-service";
import { startProviderBackgroundWorker } from "./app/provider-background-worker";
import { recoverInterruptedWork } from "./app/startup-recovery";
import { startTaskQueueWorker } from "./app/task-queue-worker";
@@ -30,6 +33,7 @@ if (!app.requestSingleInstanceLock()) {
mainWindow: win,
artifactRoot: join(profileDir, "artifacts"),
});
syncAllOrganizationSkills(connection.db, join(profileDir, "artifacts"));
const stopProviderBackgroundWorker = startProviderBackgroundWorker({
db: connection.db,
mainWindow: win,
@@ -55,3 +59,21 @@ if (!app.requestSingleInstanceLock()) {
if (process.platform !== "darwin") app.quit();
});
}
/**
* Loads the on-disk skill library for every organization at startup. One
* organization's failure (a broken workspace path, say) must not block the
* rest — or the app.
*/
function syncAllOrganizationSkills(db: DatabaseSync, artifactRoot: string): void {
const skills = new SkillService(db, artifactRoot);
const orgs = db.prepare("select id from organizations").all() as Array<{ id: string }>;
for (const org of orgs) {
try {
syncSkillsFromDisk(db, skills, org.id);
} catch {
// Broken directories are reported per entry inside the sync; anything
// beyond that is a per-org environment fault that must not stop startup.
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { RISK_LEVEL } from "../../shared/domain";
import { loadSkillManifest, type SkillManifest } from "./skill-manifest";
import type { SkillService } from "./skill-service";
/**
* Skills live on disk the way developers keep them: one directory per skill
* under the user's global `~/.agents/skills` and under each workspace's
* `.agents/skills`. A directory qualifies with either a full
* skill.manifest.json or just a SKILL.md; for the latter the manifest is
* derived (name from frontmatter or the directory name, SKILL.md as the
* entrypoint). Paths are joined per platform — Windows, macOS, and Linux
* all resolve the same way.
*/
export interface SkillSyncReport {
imported: number;
skipped: number;
failures: string[];
}
export function defaultGlobalSkillsRoot(): string {
// Same isolation seam as AGENTCOMPANY_NEW_PROFILE_DIR: tests point this at
// a temporary directory so they never read the developer's real library.
return process.env.AGENTCOMPANY_NEW_SKILLS_ROOT ?? join(homedir(), ".agents", "skills");
}
export function discoverSkillDirectories(workspaceRoots: string[], globalRoot: string = defaultGlobalSkillsRoot()): string[] {
const roots = [globalRoot, ...workspaceRoots.map((root) => join(root, ".agents", "skills"))];
const found: string[] = [];
for (const root of roots) {
if (!existsSync(root)) continue;
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = join(root, entry.name);
if (existsSync(join(dir, "skill.manifest.json")) || existsSync(join(dir, "SKILL.md"))) {
found.push(dir);
}
}
}
return found;
}
/** Full manifest when present; otherwise derived from SKILL.md. */
export function manifestForDirectory(directoryPath: string): SkillManifest {
if (existsSync(join(directoryPath, "skill.manifest.json"))) {
return loadSkillManifest(directoryPath);
}
const skillMd = readFileSync(join(directoryPath, "SKILL.md"), "utf8");
return {
name: frontmatterName(skillMd) ?? basename(directoryPath),
version: "1.0.0",
entrypoint: "SKILL.md",
capabilities: [],
requiredTools: [],
compatibleModels: [],
riskLevel: RISK_LEVEL.LOW,
tests: [],
inputs: {},
outputs: {},
};
}
/**
* Imports every discovered skill the org does not already carry. Individual
* bad directories are reported and skipped so one broken entry cannot block
* the rest of the library.
*/
export function syncSkillsFromDisk(
db: DatabaseSync,
skills: SkillService,
orgId: string,
globalRoot: string = defaultGlobalSkillsRoot(),
): SkillSyncReport {
const workspaceRoots = (db.prepare("select local_path from workspaces where org_id = ?").all(orgId) as Array<{ local_path: string }>)
.map((row) => row.local_path);
const report: SkillSyncReport = { imported: 0, skipped: 0, failures: [] };
for (const directory of discoverSkillDirectories(workspaceRoots, globalRoot)) {
try {
const manifest = manifestForDirectory(directory);
if (skills.hasSkill(orgId, manifest.name)) {
report.skipped += 1;
continue;
}
skills.importManifest(orgId, directory, manifest);
report.imported += 1;
} catch (error) {
report.failures.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return report;
}
function frontmatterName(markdown: string): string | null {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
if (!match) return null;
const nameLine = match[1].split(/\r?\n/).find((line) => /^name\s*:/.test(line.trim()));
if (!nameLine) return null;
const value = nameLine.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g, "");
return value || null;
}
+2 -2
View File
@@ -16,12 +16,12 @@ export interface SkillManifest {
outputs: unknown;
}
export function loadSkillManifest(directoryPath: string, requestedVersion: string): SkillManifest {
export function loadSkillManifest(directoryPath: string, requestedVersion?: string): SkillManifest {
const manifestPath = join(directoryPath, "skill.manifest.json");
if (!existsSync(manifestPath)) throw new Error("skill.manifest.json is required.");
const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
const manifest = normalizeManifest(raw);
if (manifest.version !== requestedVersion) {
if (requestedVersion !== undefined && manifest.version !== requestedVersion) {
throw new Error("Skill manifest version must match the requested version.");
}
verifyDeclaredFile(directoryPath, manifest.entrypoint);
+16 -6
View File
@@ -25,13 +25,23 @@ export class SkillService {
importDirectory(input: ImportSkillDirectoryInput): string {
const manifest = loadSkillManifest(input.directoryPath, input.versionSemver);
return this.importManifest(input.orgId, input.directoryPath, manifest);
}
/** True when the org already carries a skill with this name. */
hasSkill(orgId: string, name: string): boolean {
const slug = slugify(name);
return Boolean(this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?").get(orgId, slug));
}
importManifest(orgId: string, directoryPath: string, manifest: SkillManifest): string {
const slug = slugify(manifest.name);
if (!slug) throw new Error("Skill name could not be derived.");
const now = new Date().toISOString();
const skillId = createId("skl");
const revisionId = createId("skr");
const checksum = recursiveSkillChecksum(input.directoryPath);
const copiedFiles = copySkillRevision(input.directoryPath, this.artifactRoot, revisionId);
const checksum = recursiveSkillChecksum(directoryPath);
const copiedFiles = copySkillRevision(directoryPath, this.artifactRoot, revisionId);
const copiedEntrypoint = copiedFiles.find((file) => file.relativePath === manifest.entrypoint);
if (!copiedEntrypoint) throw new Error("Skill manifest entrypoint was not found.");
@@ -39,10 +49,10 @@ export class SkillService {
insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at)
values (?, ?, ?, ?, ?, ?, ?)
on conflict(org_id, slug) do update set name = excluded.name, status = 'active'
`).run(skillId, input.orgId, slug, manifest.name, "organization", "active", now);
`).run(skillId, orgId, slug, manifest.name, "organization", "active", now);
const existing = this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?")
.get(input.orgId, slug) as { id: string };
.get(orgId, slug) as { id: string };
this.db.prepare(`
insert into skill_revisions(
@@ -54,7 +64,7 @@ export class SkillService {
manifest.version,
JSON.stringify(manifest),
"local_directory",
input.directoryPath,
directoryPath,
now,
checksum,
);
@@ -72,7 +82,7 @@ export class SkillService {
}
this.audit.record({
orgId: input.orgId,
orgId,
entityType: "skill_revision",
entityId: revisionId,
actorType: "system",
+1
View File
@@ -56,6 +56,7 @@ const api: DesktopApi = {
streamProviderResponse: (input) => invoke(IPC_CHANNELS.streamProviderResponse, input),
cancelProviderResponse: (input) => invoke(IPC_CHANNELS.cancelProviderResponse, input),
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
syncSkillsFromDisk: (input) => invoke(IPC_CHANNELS.syncSkillsFromDisk, input),
bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input),
resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input),
runSkillTests: (input) => invoke(IPC_CHANNELS.runSkillTests, input),
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { Button, Field, Icon, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type {
@@ -23,6 +23,12 @@ interface SkillsPanelProps {
export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents, actions }: SkillsPanelProps) {
const [importOpen, setImportOpen] = useState(false);
// The on-disk skill library (global ~/.agents/skills and each workspace's
// .agents/skills) syncs in when the panel opens.
useEffect(() => {
void actions.run(() => window.agentCompany.syncSkillsFromDisk({ orgId }));
}, [orgId, actions]);
const importSkill = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
+4
View File
@@ -4,6 +4,10 @@ export interface ImportSkillDirectoryInput {
versionSemver: string;
}
export interface SyncSkillsFromDiskInput {
orgId: string;
}
export interface BindSkillInput {
orgId: string;
agentId: string;
+2
View File
@@ -61,6 +61,7 @@ import type {
RunSdlcReviewInput,
RunSkillTestsInput,
RunTaskInput,
SyncSkillsFromDiskInput,
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
ComposioToolkitSummaryDto,
@@ -152,6 +153,7 @@ export interface DesktopApi {
streamProviderResponse(input: StreamProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
cancelProviderResponse(input: CancelProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
syncSkillsFromDisk(input: SyncSkillsFromDiskInput): Promise<DesktopResult<AppSnapshot>>;
bindSkill(input: BindSkillInput): Promise<DesktopResult<AppSnapshot>>;
resolveAgentSkills(input: ResolveAgentSkillsInput): Promise<DesktopResult<AppSnapshot>>;
runSkillTests(input: RunSkillTestsInput): Promise<DesktopResult<AppSnapshot>>;
+1
View File
@@ -33,6 +33,7 @@ export const IPC_CHANNELS = {
streamProviderResponse: "agentcompany:stream-provider-response",
cancelProviderResponse: "agentcompany:cancel-provider-response",
importSkillDirectory: "agentcompany:import-skill-directory",
syncSkillsFromDisk: "agentcompany:sync-skills-from-disk",
bindSkill: "agentcompany:bind-skill",
resolveAgentSkills: "agentcompany:resolve-agent-skills",
runSkillTests: "agentcompany:run-skill-tests",
+19 -1
View File
@@ -7,12 +7,18 @@ import { randomUUID } from "node:crypto";
test("onboards, hires, and creates work in the Electron shell", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
// The app reads skills from the user's global library, so the test gets a
// library of its own — seeded with one on-disk skill the Skills page must
// surface without a manual import.
const skillsRoot = join(tmpdir(), `agentcompany-new-e2e-skills-${randomUUID()}`);
createGlobalDiskSkill(skillsRoot);
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: {
...process.env,
AGENTCOMPANY_NEW_PROFILE_DIR: profileDir,
AGENTCOMPANY_NEW_AUTORUN: "0",
AGENTCOMPANY_NEW_SKILLS_ROOT: skillsRoot,
},
});
@@ -138,12 +144,14 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await expect(page.getByText("0 / 25").first()).toBeVisible();
await page.getByRole("button", { name: "Skills" }).click();
// The seeded home-directory skill syncs in on its own when the page opens.
await expect(page.getByText("Disk Runbook").first()).toBeVisible();
await page.getByRole("button", { name: "Import" }).click();
await page.getByLabel("Directory path").fill(createSkillDirectory());
await page.locator(".modal").getByRole("button", { name: "Import" }).click();
await expect(page.getByText("Release Gate").first()).toBeVisible();
await expect(page.getByText("Never run").first()).toBeVisible();
await page.getByRole("button", { name: "Run tests" }).click();
await page.locator(".table-row").filter({ hasText: "Release Gate" }).getByRole("button", { name: "Run tests" }).click();
await expect(page.getByText("1/1 passed").first()).toBeVisible();
// Virtual office page
@@ -163,6 +171,16 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
}
});
function createGlobalDiskSkill(skillsRoot: string): void {
const dir = join(skillsRoot, "disk-runbook");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "SKILL.md"),
"---\nname: Disk Runbook\ndescription: Recorded incident response steps.\n---\n\nFollow the recorded steps.\n",
"utf8",
);
}
function createSkillDirectory(): string {
const root = join(tmpdir(), `agentcompany-skill-${randomUUID()}`);
mkdirSync(join(root, "checks"), { recursive: true });
+6 -1
View File
@@ -11,7 +11,12 @@ test("the office floor opens agent details on click", async () => {
mkdirSync(profileDir, { recursive: true });
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0" },
env: {
...process.env,
AGENTCOMPANY_NEW_PROFILE_DIR: profileDir,
AGENTCOMPANY_NEW_AUTORUN: "0",
AGENTCOMPANY_NEW_SKILLS_ROOT: join(profileDir, "skills-root"),
},
});
try {
+6 -1
View File
@@ -9,7 +9,12 @@ test("captures every page for visual review", async () => {
mkdirSync(profileDir, { recursive: true });
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0" },
env: {
...process.env,
AGENTCOMPANY_NEW_PROFILE_DIR: profileDir,
AGENTCOMPANY_NEW_AUTORUN: "0",
AGENTCOMPANY_NEW_SKILLS_ROOT: join(profileDir, "skills-root"),
},
});
const outDir = resolve("test-results/screenshots");
mkdirSync(outDir, { recursive: true });
+108
View File
@@ -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"]);
});
});