From 810034b13a55280d56aff834e656d687b51a03e1 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Wed, 1 Jul 2026 22:28:28 +0530 Subject: [PATCH 1/2] feat: disk-backed agent skills with live-reload --- apps/x/apps/main/src/main.ts | 6 + .../src/application/assistant/instructions.ts | 6 +- .../assistant/skills/disk-loader.ts | 99 ++++++++++++++++ .../src/application/assistant/skills/index.ts | 112 ++++++++++++++---- .../application/assistant/skills/watcher.ts | 87 ++++++++++++++ apps/x/packages/core/src/config/config.ts | 1 + 6 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 apps/x/packages/core/src/application/assistant/skills/disk-loader.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/watcher.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 81d43553..30de0c1e 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -31,6 +31,7 @@ import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-co import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js"; import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; +import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; @@ -345,6 +346,10 @@ app.whenReady().then(async () => { // start bg-task scheduler (cron / window) initBackgroundTaskScheduler(); + // start disk-skills watcher: live-reload skills dropped into + // ~/.rowboat/skills or ~/.agents/skills without an app restart + startSkillsWatcher(); + // register event consumers and start the shared event processor // (consumes $WorkDir/events/pending/, routes events to all consumers // concurrently for Pass-1, then fires each consumer's candidates in parallel) @@ -416,6 +421,7 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + stopSkillsWatcher(); shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index b3611fe4..b0094944 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -335,9 +335,9 @@ export async function buildCopilotInstructions(): Promise { const excludeIds: string[] = []; if (!composioEnabled) excludeIds.push('composio-integration'); if (!codeModeEnabled) excludeIds.push('code-with-agents'); - const catalog = excludeIds.length > 0 - ? buildSkillCatalog({ excludeIds }) - : skillCatalog; + // Always build from the live skill set so disk skills added/removed at + // runtime (after refreshDiskSkills + cache invalidation) are reflected. + const catalog = buildSkillCatalog({ excludeIds }); const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled); const composioPrompt = await getComposioToolsPrompt(); cachedInstructions = composioPrompt diff --git a/apps/x/packages/core/src/application/assistant/skills/disk-loader.ts b/apps/x/packages/core/src/application/assistant/skills/disk-loader.ts new file mode 100644 index 00000000..510e4140 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/disk-loader.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; +import { WorkDir } from "../../../config/config.js"; +import { splitFrontmatter } from "../../lib/parse-frontmatter.js"; + +/** + * A skill discovered on disk. Matches the bundled SkillDefinition shape + * (id/title/summary/content) plus provenance about where it came from. + */ +export type DiskSkill = { + id: string; // slugified folder name + title: string; // frontmatter `name` + summary: string; // frontmatter `description` + content: string; // full raw SKILL.md text + dir: string; // absolute path to the skill folder + skillFile: string; // absolute path to the SKILL.md +}; + +// Locations scanned for /SKILL.md subfolders. The rowboat root is +// derived from WorkDir so it honors the ROWBOAT_WORKDIR override (defaults to +// ~/.rowboat/skills); it is scanned first so it wins on id collisions across +// the two roots. The ~/.agents/skills root is an external shared convention, +// not tied to WorkDir. Exported so the live-reload watcher watches exactly the +// same locations. +export const SKILL_ROOTS = [ + path.join(WorkDir, "skills"), + path.join(homedir(), ".agents", "skills"), +]; + +const slugify = (value: string): string => + value.trim().toLowerCase().replace(/[\s_]+/g, "-"); + +/** + * Synchronously scan the known skill roots (one level deep) for disk skills. + * A missing/empty directory or an unreadable/invalid SKILL.md simply yields no + * skill for that folder — this function never throws. + */ +export function loadDiskSkills(): DiskSkill[] { + const skills: DiskSkill[] = []; + const seen = new Map(); // id -> dir that claimed it + + for (const root of SKILL_ROOTS) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + // Directory missing (e.g. ~/.agents/skills absent) — nothing to scan. + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dir = path.join(root, entry.name); + const skillFile = path.join(dir, "SKILL.md"); + + let raw: string; + try { + raw = fs.readFileSync(skillFile, "utf8"); + } catch { + console.warn(`[disk-skills] Skipping '${entry.name}': no readable SKILL.md at ${skillFile}`); + continue; + } + + // Parse with the real YAML frontmatter helper so folded/multi-line + // scalars and nested keys (e.g. `metadata:`) are handled correctly. + let frontmatter: Record; + try { + ({ frontmatter } = splitFrontmatter(raw)); + } catch (err) { + console.warn(`[disk-skills] Skipping '${entry.name}': failed to parse frontmatter:`, err); + continue; + } + + // Coerce a scalar (including folded/multi-line) frontmatter value to a + // single trimmed string; anything non-string yields "". + const asString = (value: unknown): string => + typeof value === "string" ? value.trim() : ""; + const name = asString(frontmatter.name); + const description = asString(frontmatter.description); + if (!name || !description) { + console.warn(`[disk-skills] Skipping '${entry.name}': SKILL.md is missing required 'name' and/or 'description' frontmatter.`); + continue; + } + + const id = slugify(entry.name); + const existing = seen.get(id); + if (existing) { + console.warn(`[disk-skills] Duplicate skill id '${id}' from ${dir} ignored; already provided by ${existing}.`); + continue; + } + seen.set(id, dir); + + skills.push({ id, title: name, summary: description, content: raw, dir, skillFile }); + } + } + + return skills; +} diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 30ceea95..9752338f 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { loadDiskSkills } from "./disk-loader.js"; import builtinToolsSkill from "./builtin-tools/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; import docCollabSkill from "./doc-collab/skill.js"; @@ -128,32 +129,56 @@ const definitions: SkillDefinition[] = [ }, ]; -const skillEntries = definitions.map((definition) => ({ +type SkillEntry = SkillDefinition & { catalogPath: string; skillFile?: string }; + +const bundledEntries: SkillEntry[] = definitions.map((definition) => ({ ...definition, catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`, })); -const catalogSections = skillEntries.map((entry) => [ - `## ${entry.title}`, - `- **Skill file:** \`${entry.catalogPath}\``, - `- **Use it for:** ${entry.summary}`, -].join("\n")); +// A disk skill may not shadow a bundled skill: on id collision, bundled wins. +const bundledIds = new Set(definitions.map((d) => d.id)); -export const skillCatalog = [ - "# Rowboat Skill Catalog", - "", - "Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.", - "", - catalogSections.join("\n\n"), -].join("\n"); +// ---- Disk skill layer (refreshable at runtime) ---- +// Bundled skills are static. Disk skills (~/.rowboat/skills, ~/.agents/skills) +// can be added/edited/removed while the app runs, so they live in a mutable +// list rebuilt by refreshDiskSkills() and resolved via a separate alias map. +let diskEntries: SkillEntry[] = []; +const diskAliasMap = new Map(); + +function loadDiskEntries(): SkillEntry[] { + return loadDiskSkills() + .filter((skill) => { + if (bundledIds.has(skill.id)) { + console.warn(`[disk-skills] Disk skill '${skill.id}' (${skill.dir}) shadows a built-in skill; keeping the built-in.`); + return false; + } + return true; + }) + .map((skill) => ({ + id: skill.id, + title: skill.title, + summary: skill.summary, + content: skill.content, + // For disk skills the catalog/loadSkill reference is the absolute SKILL.md path. + catalogPath: skill.skillFile, + skillFile: skill.skillFile, + })); +} + +// The full, current skill set (bundled first so it wins on any collision). +function getSkillEntries(): SkillEntry[] { + return [...bundledEntries, ...diskEntries]; +} /** * Build a skill catalog string, optionally excluding specific skills by ID. + * Reads the live skill set, so it reflects disk skills added/removed at runtime. */ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string { const entries = options?.excludeIds - ? skillEntries.filter(e => !options.excludeIds!.includes(e.id)) - : skillEntries; + ? getSkillEntries().filter(e => !options.excludeIds!.includes(e.id)) + : getSkillEntries(); const sections = entries.map((entry) => [ `## ${entry.title}`, `- **Skill file:** \`${entry.catalogPath}\``, @@ -171,15 +196,17 @@ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string { const normalizeIdentifier = (value: string) => value.trim().replace(/\\/g, "/").replace(/^\.\/+/, ""); +// Bundled aliases are static; disk aliases live in diskAliasMap so they can be +// rebuilt wholesale on refresh without disturbing the bundled entries. const aliasMap = new Map(); -const registerAlias = (alias: string, entry: ResolvedSkill) => { +const registerAlias = (alias: string, entry: ResolvedSkill, target: Map = aliasMap) => { const normalized = normalizeIdentifier(alias); if (!normalized) return; - aliasMap.set(normalized, entry); + target.set(normalized, entry); }; -const registerAliasVariants = (alias: string, entry: ResolvedSkill) => { +const registerAliasVariants = (alias: string, entry: ResolvedSkill, target: Map = aliasMap) => { const normalized = normalizeIdentifier(alias); if (!normalized) return; @@ -196,11 +223,11 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => { } for (const variant of variants) { - registerAlias(variant, entry); + registerAlias(variant, entry, target); } }; -for (const entry of skillEntries) { +for (const entry of bundledEntries) { const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts"); const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js"); const resolvedEntry: ResolvedSkill = { @@ -227,11 +254,52 @@ for (const entry of skillEntries) { } } -export const availableSkills = skillEntries.map((entry) => entry.id); +// Disk skills resolve by their id and by their absolute on-disk SKILL.md path, +// so the existing loadSkill tool can load them unchanged. Rebuilt on refresh. +function rebuildDiskAliases(): void { + diskAliasMap.clear(); + for (const entry of diskEntries) { + const resolvedEntry: ResolvedSkill = { + id: entry.id, + catalogPath: entry.catalogPath, + content: entry.content, + }; + registerAlias(entry.id, resolvedEntry, diskAliasMap); + if (entry.skillFile) { + registerAlias(entry.skillFile, resolvedEntry, diskAliasMap); + } + } +} + +// Live-updated in place (same array binding) so importers see current ids. +export const availableSkills: string[] = []; + +/** + * Re-scan disk skills and rebuild the disk catalog/alias entries. Bundled + * skills are untouched. Callers (the disk-skills watcher) should follow this + * with invalidateCopilotInstructionsCache() so the next agent run picks it up. + * Returns the number of disk skills currently loaded. + */ +export function refreshDiskSkills(): number { + diskEntries = loadDiskEntries(); + rebuildDiskAliases(); + availableSkills.length = 0; + for (const entry of getSkillEntries()) availableSkills.push(entry.id); + return diskEntries.length; +} + +// Initial disk load at module init. +refreshDiskSkills(); + +// Snapshot of the catalog at module load, kept for backward-compatible +// consumers (e.g. the legacy CopilotInstructions export). Runtime consumers +// should call buildSkillCatalog() to get the live set. +export const skillCatalog = buildSkillCatalog(); export function resolveSkill(identifier: string): ResolvedSkill | null { const normalized = normalizeIdentifier(identifier); if (!normalized) return null; - return aliasMap.get(normalized) ?? null; + // Bundled wins over disk on any alias collision. + return aliasMap.get(normalized) ?? diskAliasMap.get(normalized) ?? null; } diff --git a/apps/x/packages/core/src/application/assistant/skills/watcher.ts b/apps/x/packages/core/src/application/assistant/skills/watcher.ts new file mode 100644 index 00000000..9e5fd459 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/watcher.ts @@ -0,0 +1,87 @@ +import chokidar, { type FSWatcher } from "chokidar"; +import fs from "node:fs"; +import { SKILL_ROOTS } from "./disk-loader.js"; +import { refreshDiskSkills } from "./index.js"; +import { invalidateCopilotInstructionsCache } from "../instructions.js"; + +// The skills CLI writes many files per install (~13 paths for one skill), so we +// coalesce a burst of events into a single refresh. +const DEBOUNCE_MS = 400; + +let watcher: FSWatcher | null = null; +let debounceTimer: NodeJS.Timeout | null = null; + +function scheduleReload(): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + try { + const count = refreshDiskSkills(); + invalidateCopilotInstructionsCache(); + console.log(`[disk-skills] reloaded ${count} skills after change`); + } catch (err) { + // A bad SKILL.md (or any refresh error) must never crash the app. + console.error("[disk-skills] failed to reload after change:", err); + } + }, DEBOUNCE_MS); +} + +/** + * Start watching the disk skill roots (~/.rowboat/skills, ~/.agents/skills) for + * changes and live-reload the skill catalog. Idempotent. Missing directories + * are skipped so watching ~/.agents when it doesn't exist never errors. + */ +export function startSkillsWatcher(): void { + if (watcher) return; + + const roots = SKILL_ROOTS.filter((root) => { + try { + return fs.statSync(root).isDirectory(); + } catch { + return false; // Directory absent (e.g. ~/.agents/skills) — nothing to watch. + } + }); + + if (roots.length === 0) { + console.log("[disk-skills] no skill directories present; watcher idle"); + return; + } + + try { + watcher = chokidar.watch(roots, { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 150, + pollInterval: 50, + }, + }); + + // Any add/change/unlink (files or dirs) under a root triggers a debounced reload. + watcher + .on("add", scheduleReload) + .on("change", scheduleReload) + .on("unlink", scheduleReload) + .on("addDir", scheduleReload) + .on("unlinkDir", scheduleReload) + .on("error", (error: unknown) => { + console.error("[disk-skills] watcher error:", error); + }); + + console.log(`[disk-skills] watching ${roots.length} skill director${roots.length === 1 ? "y" : "ies"} for changes`); + } catch (err) { + console.error("[disk-skills] failed to start watcher:", err); + watcher = null; + } +} + +/** Stop the skills watcher and clear any pending reload. Idempotent. */ +export function stopSkillsWatcher(): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + if (watcher) { + watcher.close().catch(() => { /* ignore close errors on shutdown */ }); + watcher = null; + } +} diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index 7f230514..94c6db79 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -33,6 +33,7 @@ function ensureDirs() { ensure(path.join(WorkDir, "agents")); ensure(path.join(WorkDir, "config")); ensure(path.join(WorkDir, "knowledge")); + ensure(path.join(WorkDir, "skills")); } function ensureDefaultConfigs() { From b8b4589ff255ef97cf05921c5ef74d8c71848210 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Mon, 6 Jul 2026 19:42:00 +0530 Subject: [PATCH 2/2] test: unit tests for disk skills loader + catalog merge (11 cases) --- .../assistant/skills/disk-loader.test.ts | 223 ++++++++++++++++++ .../assistant/skills/index.test.ts | 148 ++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/index.test.ts diff --git a/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts b/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts new file mode 100644 index 00000000..b0682c62 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts @@ -0,0 +1,223 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// SKILL_ROOTS is computed when disk-loader.js loads, from WorkDir (resolved in +// config.js from ROWBOAT_WORKDIR) and homedir(). Both overrides must be in +// place before the module is imported — hence resetModules + dynamic import +// per test, mirroring filesystem/files.test.ts. +let tmpDir: string; +let workDir: string; +let fakeHomeDir: string; +let rowboatSkillsRoot: string; +let agentsSkillsRoot: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disk-skills-test-")); + workDir = path.join(tmpDir, "rowboat"); + fakeHomeDir = path.join(tmpDir, "home"); + rowboatSkillsRoot = path.join(workDir, "skills"); + agentsSkillsRoot = path.join(fakeHomeDir, ".agents", "skills"); + process.env.ROWBOAT_WORKDIR = workDir; + vi.resetModules(); + // config.js kicks off these imports on load; stub them so tests stay + // hermetic (no git init or Today.md migration against the temp workdir). + vi.doMock("../../../knowledge/version_history.js", () => ({ + commitAll: vi.fn(async () => undefined), + initRepo: vi.fn(async () => undefined), + })); + vi.doMock("../../../knowledge/deprecate_today_note.js", () => ({ + deprecateTodayNote: vi.fn(async () => undefined), + })); + // Point homedir() at the temp dir so the ~/.agents/skills root never touches + // the developer's real home directory. + vi.doMock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => fakeHomeDir, + default: { ...actual, homedir: () => fakeHomeDir }, + }; + }); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + delete process.env.ROWBOAT_WORKDIR; + vi.doUnmock("../../../knowledge/version_history.js"); + vi.doUnmock("../../../knowledge/deprecate_today_note.js"); + vi.doUnmock("node:os"); + vi.resetModules(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function loadDiskLoader() { + return import("./disk-loader.js"); +} + +function writeSkill(root: string, folder: string, contents: string): string { + const dir = path.join(root, folder); + fs.mkdirSync(dir, { recursive: true }); + const skillFile = path.join(dir, "SKILL.md"); + fs.writeFileSync(skillFile, contents); + return skillFile; +} + +describe("SKILL_ROOTS", () => { + it("derives the rowboat root from ROWBOAT_WORKDIR and the agents root from homedir", async () => { + const { SKILL_ROOTS } = await loadDiskLoader(); + + expect(SKILL_ROOTS).toEqual([rowboatSkillsRoot, agentsSkillsRoot]); + }); +}); + +describe("loadDiskSkills", () => { + it("loads a valid skill with id/title/summary, raw content, and absolute paths", async () => { + const raw = [ + "---", + "name: Test Skill", + "description: Does test things.", + "---", + "", + "# Test Skill", + "", + "Body of the skill.", + "", + ].join("\n"); + const skillFile = writeSkill(rowboatSkillsRoot, "test-skill", raw); + + const { loadDiskSkills } = await loadDiskLoader(); + const skills = loadDiskSkills(); + + expect(skills).toHaveLength(1); + const skill = skills[0]; + expect(skill.id).toBe("test-skill"); + expect(skill.title).toBe("Test Skill"); + expect(skill.summary).toBe("Does test things."); + expect(skill.content).toBe(raw); + expect(skill.dir).toBe(path.join(rowboatSkillsRoot, "test-skill")); + expect(path.isAbsolute(skill.dir)).toBe(true); + expect(skill.skillFile).toBe(skillFile); + expect(path.isAbsolute(skill.skillFile)).toBe(true); + }); + + it("handles folded multi-line descriptions and nested metadata blocks", async () => { + const raw = [ + "---", + "name: vercel-deploy", + "description: >-", + " Deploy a project to Vercel", + " with zero configuration.", + "metadata:", + " category: deployment", + " tags:", + " - vercel", + " - deploy", + "---", + "", + "Instructions here.", + "", + ].join("\n"); + writeSkill(rowboatSkillsRoot, "vercel-deploy", raw); + + const { loadDiskSkills } = await loadDiskLoader(); + const skills = loadDiskSkills(); + + expect(skills).toHaveLength(1); + expect(skills[0].title).toBe("vercel-deploy"); + expect(skills[0].summary).toBe("Deploy a project to Vercel with zero configuration."); + expect(skills[0].content).toBe(raw); + }); + + it("skips skills missing name or description without throwing", async () => { + writeSkill(rowboatSkillsRoot, "no-description", [ + "---", + "name: No Description", + "---", + "Body.", + ].join("\n")); + writeSkill(rowboatSkillsRoot, "no-name", [ + "---", + "description: Has no name.", + "---", + "Body.", + ].join("\n")); + writeSkill(rowboatSkillsRoot, "valid", [ + "---", + "name: Valid", + "description: The only loadable one.", + "---", + "Body.", + ].join("\n")); + + const { loadDiskSkills } = await loadDiskLoader(); + const skills = loadDiskSkills(); + + expect(skills.map((s) => s.id)).toEqual(["valid"]); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("Skipping 'no-description'"), + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("Skipping 'no-name'"), + ); + }); + + it("skips folders without a SKILL.md", async () => { + fs.mkdirSync(path.join(rowboatSkillsRoot, "empty-folder"), { recursive: true }); + fs.writeFileSync(path.join(rowboatSkillsRoot, "stray-file.md"), "not a skill folder"); + + const { loadDiskSkills } = await loadDiskLoader(); + + expect(loadDiskSkills()).toEqual([]); + }); + + it("prefers the rowboat root when the same skill id exists in both roots", async () => { + writeSkill(rowboatSkillsRoot, "dup", [ + "---", + "name: Rowboat Dup", + "description: From the rowboat root.", + "---", + "Rowboat body.", + ].join("\n")); + writeSkill(agentsSkillsRoot, "dup", [ + "---", + "name: Agents Dup", + "description: From the agents root.", + "---", + "Agents body.", + ].join("\n")); + + const { loadDiskSkills } = await loadDiskLoader(); + const skills = loadDiskSkills(); + + expect(skills).toHaveLength(1); + expect(skills[0].title).toBe("Rowboat Dup"); + expect(skills[0].dir).toBe(path.join(rowboatSkillsRoot, "dup")); + }); + + it("returns [] when the roots do not exist", async () => { + // config.js creates the rowboat skills dir on load, so import first, then + // remove it. The agents root was never created. + const { loadDiskSkills } = await loadDiskLoader(); + fs.rmSync(rowboatSkillsRoot, { recursive: true, force: true }); + + expect(loadDiskSkills()).toEqual([]); + }); + + it("slugifies folder names into skill ids", async () => { + writeSkill(rowboatSkillsRoot, "My_Skill Name", [ + "---", + "name: My Skill", + "description: Slug test.", + "---", + "Body.", + ].join("\n")); + + const { loadDiskSkills } = await loadDiskLoader(); + const skills = loadDiskSkills(); + + expect(skills).toHaveLength(1); + expect(skills[0].id).toBe("my-skill-name"); + }); +}); diff --git a/apps/x/packages/core/src/application/assistant/skills/index.test.ts b/apps/x/packages/core/src/application/assistant/skills/index.test.ts new file mode 100644 index 00000000..e8b4e6ed --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/index.test.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// index.js scans the disk skill roots at module init, and the roots are fixed +// when disk-loader.js/config.js load — so the ROWBOAT_WORKDIR and homedir +// overrides must be in place before the dynamic import in each test, mirroring +// filesystem/files.test.ts. +let tmpDir: string; +let workDir: string; +let fakeHomeDir: string; +let rowboatSkillsRoot: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-index-test-")); + workDir = path.join(tmpDir, "rowboat"); + fakeHomeDir = path.join(tmpDir, "home"); + rowboatSkillsRoot = path.join(workDir, "skills"); + process.env.ROWBOAT_WORKDIR = workDir; + vi.resetModules(); + // config.js kicks off these imports on load; stub them so tests stay + // hermetic (no git init or Today.md migration against the temp workdir). + vi.doMock("../../../knowledge/version_history.js", () => ({ + commitAll: vi.fn(async () => undefined), + initRepo: vi.fn(async () => undefined), + })); + vi.doMock("../../../knowledge/deprecate_today_note.js", () => ({ + deprecateTodayNote: vi.fn(async () => undefined), + })); + // Point homedir() at the temp dir so the ~/.agents/skills root never touches + // the developer's real home directory. + vi.doMock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => fakeHomeDir, + default: { ...actual, homedir: () => fakeHomeDir }, + }; + }); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + delete process.env.ROWBOAT_WORKDIR; + vi.doUnmock("../../../knowledge/version_history.js"); + vi.doUnmock("../../../knowledge/deprecate_today_note.js"); + vi.doUnmock("node:os"); + vi.resetModules(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function loadSkillsIndex() { + return import("./index.js"); +} + +function writeSkill(folder: string, contents: string): string { + const dir = path.join(rowboatSkillsRoot, folder); + fs.mkdirSync(dir, { recursive: true }); + const skillFile = path.join(dir, "SKILL.md"); + fs.writeFileSync(skillFile, contents); + return skillFile; +} + +describe("skills index (disk skill merge layer)", () => { + it("includes disk skills in the catalog and resolves them by id and file path", async () => { + const raw = [ + "---", + "name: Ship It", + "description: Ship the thing to production.", + "---", + "", + "# Ship It", + "", + "Disk skill body.", + "", + ].join("\n"); + const skillFile = writeSkill("ship-it", raw); + + const skills = await loadSkillsIndex(); + + expect(skills.availableSkills).toContain("ship-it"); + + const catalog = skills.buildSkillCatalog(); + expect(catalog).toContain("## Ship It"); + expect(catalog).toContain(skillFile); + expect(catalog).toContain("Ship the thing to production."); + + expect(skills.resolveSkill("ship-it")?.content).toBe(raw); + expect(skills.resolveSkill(skillFile)?.content).toBe(raw); + expect(skills.resolveSkill("ship-it")?.catalogPath).toBe(skillFile); + }); + + it("keeps the bundled skill when a disk skill uses the same id", async () => { + writeSkill("meeting-prep", [ + "---", + "name: Shadow Meeting Prep", + "description: Disk impostor.", + "---", + "DISK CONTENT MARKER", + ].join("\n")); + + const skills = await loadSkillsIndex(); + + const resolved = skills.resolveSkill("meeting-prep"); + expect(resolved?.catalogPath).toBe("src/application/assistant/skills/meeting-prep/skill.ts"); + expect(resolved?.content).not.toContain("DISK CONTENT MARKER"); + + const catalog = skills.buildSkillCatalog(); + expect(catalog).toContain("## Meeting Prep"); + expect(catalog).not.toContain("Shadow Meeting Prep"); + expect(skills.availableSkills.filter((id) => id === "meeting-prep")).toHaveLength(1); + }); + + it("refreshDiskSkills picks up skills added and removed on disk", async () => { + const skills = await loadSkillsIndex(); + + expect(skills.resolveSkill("late-arrival")).toBeNull(); + expect(skills.availableSkills).not.toContain("late-arrival"); + + const raw = [ + "---", + "name: Late Arrival", + "description: Added after module init.", + "---", + "Late body.", + ].join("\n"); + const skillFile = writeSkill("late-arrival", raw); + + // Not visible until refreshed. + expect(skills.resolveSkill("late-arrival")).toBeNull(); + expect(skills.buildSkillCatalog()).not.toContain("## Late Arrival"); + + expect(skills.refreshDiskSkills()).toBe(1); + expect(skills.availableSkills).toContain("late-arrival"); + expect(skills.buildSkillCatalog()).toContain("## Late Arrival"); + expect(skills.resolveSkill("late-arrival")?.content).toBe(raw); + expect(skills.resolveSkill(skillFile)?.content).toBe(raw); + + fs.rmSync(path.join(rowboatSkillsRoot, "late-arrival"), { recursive: true, force: true }); + + expect(skills.refreshDiskSkills()).toBe(0); + expect(skills.availableSkills).not.toContain("late-arrival"); + expect(skills.buildSkillCatalog()).not.toContain("## Late Arrival"); + expect(skills.resolveSkill("late-arrival")).toBeNull(); + expect(skills.resolveSkill(skillFile)).toBeNull(); + }); +});