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
@@ -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)));
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +4,10 @@ export interface ImportSkillDirectoryInput {
|
||||
versionSemver: string;
|
||||
}
|
||||
|
||||
export interface SyncSkillsFromDiskInput {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface BindSkillInput {
|
||||
orgId: string;
|
||||
agentId: string;
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user