diff --git a/lib/agents-index-e2e.test.js b/lib/agents-index-e2e.test.js index 7c5de60..f499648 100644 --- a/lib/agents-index-e2e.test.js +++ b/lib/agents-index-e2e.test.js @@ -400,4 +400,59 @@ describe("full installer workflow integration", () => { expected: true, }); }); + + test("installer creates .agents/skills symlinks for aidd-* skills", async () => { + await executeClone({ + targetDirectory: tempTestDir, + }); + + // At least one aidd-* skill must exist in the installed ai/skills/ folder + const agentsSkillsDir = path.join(tempTestDir, ".agents", "skills"); + const dirExists = await fs.pathExists(agentsSkillsDir); + + assert({ + given: "fresh installation with ai/skills/ present", + should: "create .agents/skills directory", + actual: dirExists, + expected: true, + }); + + const entries = await fs.readdir(agentsSkillsDir); + const aiddEntries = entries.filter((e) => e.startsWith("aidd-")); + + assert({ + given: "fresh installation with aidd-* skills in ai/skills/", + should: "create at least one symlink in .agents/skills/", + actual: aiddEntries.length > 0, + expected: true, + }); + + // Verify each entry is actually a symlink + const allAreSymlinks = await Promise.all( + aiddEntries.map(async (name) => { + const lstat = await fs.lstat(path.join(agentsSkillsDir, name)); + return lstat.isSymbolicLink(); + }), + ); + + assert({ + given: "entries in .agents/skills/ created by install", + should: "all be symlinks", + actual: allAreSymlinks.every(Boolean), + expected: true, + }); + + // Spot-check one symlink target is the expected relative path + const firstSkill = aiddEntries[0]; + const linkTarget = await fs.readlink( + path.join(agentsSkillsDir, firstSkill), + ); + + assert({ + given: "a symlink in .agents/skills/", + should: "point to ../../ai/skills/", + actual: linkTarget, + expected: `../../ai/skills/${firstSkill}`, + }); + }); }); diff --git a/lib/agents-setup.js b/lib/agents-setup.js index 2ba05d1..d81ff4e 100644 --- a/lib/agents-setup.js +++ b/lib/agents-setup.js @@ -1,6 +1,7 @@ import path from "path"; import { ensureAgentsMd } from "./agents-md.js"; +import { symlinkAgentsSkills } from "./agents-skills-symlink.js"; import { createAiddCustomAgentsMd, createAiddCustomConfig, @@ -25,6 +26,13 @@ const setupAgents = async ({ targetBase, verbose = false, logger }) => { verbose && logger.info("Generating aidd-custom/index.md..."); const customDir = path.join(targetBase, "aidd-custom"); await generateIndexRecursive(customDir); + + verbose && logger.info("Symlinking skills into .agents/skills/..."); + const symlinkResult = await symlinkAgentsSkills({ targetBase }); + verbose && + logger.verbose( + `.agents/skills: created ${symlinkResult.created}, skipped ${symlinkResult.skipped}`, + ); }; export { setupAgents }; diff --git a/lib/agents-setup.test.js b/lib/agents-setup.test.js index 78518b1..8b6a07c 100644 --- a/lib/agents-setup.test.js +++ b/lib/agents-setup.test.js @@ -56,6 +56,15 @@ describe("setupAgents", () => { actual: await fs.pathExists(indexPath), expected: true, }); + + // .agents/skills/ is created but no symlinks because ai/skills/ doesn't + // exist in tempDir (no ai/ folder is copied in this unit test). + assert({ + given: "a fresh target directory without ai/skills", + should: "not fail and leave .agents/skills absent", + actual: await fs.pathExists(path.join(tempDir, ".agents", "skills")), + expected: false, + }); }); test("when verbose, logs each setup step", async () => { @@ -102,5 +111,12 @@ describe("setupAgents", () => { actual: logged.verbose.length >= 3, expected: true, }); + + assert({ + given: "verbose agent setup", + should: "log the skills symlink step", + actual: logged.info.some((m) => m.includes(".agents/skills")), + expected: true, + }); }); }); diff --git a/lib/agents-skills-symlink.js b/lib/agents-skills-symlink.js new file mode 100644 index 0000000..a28453e --- /dev/null +++ b/lib/agents-skills-symlink.js @@ -0,0 +1,121 @@ +// @ts-check +/** @typedef {{ targetBase: string; dryRun?: boolean }} SymlinkOptions */ + +import path from "path"; +import fs from "fs-extra"; + +const AGENTS_SKILLS_DIR = path.join(".agents", "skills"); +const AI_SKILLS_DIR = path.join("ai", "skills"); + +/** + * Resolve all aidd-* skill directory names under ai/skills/. + * Returns an empty array when the directory does not exist. + */ +/** + * @param {string} targetBase + */ +const getAiddSkillNames = async (targetBase) => { + const skillsDir = path.join(targetBase, AI_SKILLS_DIR); + const exists = await fs.pathExists(skillsDir); + if (!exists) return []; + + const entries = await fs.readdir(skillsDir); + const results = []; + for (const entry of entries) { + if (!entry.startsWith("aidd-")) continue; + const entryPath = path.join(skillsDir, entry); + const stat = await fs.stat(entryPath); + if (stat.isDirectory()) results.push(entry); + } + return results; +}; + +/** + * Determine whether a path is already a symlink. + */ +/** + * @param {string} filePath + */ +const isSymlink = async (filePath) => { + try { + const lstat = await fs.lstat(filePath); + return lstat.isSymbolicLink(); + } catch { + return false; + } +}; + +/** + * Symlink all aidd-* skills from ai/skills/ into .agents/skills/. + * + * Rules: + * - Creates .agents/skills/ if absent (unless dryRun). + * - For each aidd-* folder in ai/skills/, creates a relative symlink at + * .agents/skills/ → ../../ai/skills/. + * - Skips entries that already exist (symlinks or real files/directories). + * - Never overwrites real (non-symlink) entries so consumers can override. + * + * Returns: + * { created: number, skipped: number, wouldCreate: string[] } + * + * `wouldCreate` is populated only in dry-run mode and lists the skill names + * that would have been symlinked. + */ +/** + * @param {SymlinkOptions} options + */ +const symlinkAgentsSkills = async ({ targetBase, dryRun = false }) => { + const skillNames = await getAiddSkillNames(targetBase); + + if (dryRun) { + const agentsSkillsDir = path.join(targetBase, AGENTS_SKILLS_DIR); + const wouldCreate = []; + + for (const name of skillNames) { + const dest = path.join(agentsSkillsDir, name); + const exists = await fs.pathExists(dest); + const alreadySymlink = await isSymlink(dest); + if (!exists || alreadySymlink) { + wouldCreate.push(name); + } + } + + return { created: 0, skipped: 0, wouldCreate }; + } + + if (skillNames.length === 0) { + return { created: 0, skipped: 0, wouldCreate: [] }; + } + + const agentsSkillsDir = path.join(targetBase, AGENTS_SKILLS_DIR); + await fs.ensureDir(agentsSkillsDir); + + let created = 0; + let skipped = 0; + + for (const name of skillNames) { + const dest = path.join(agentsSkillsDir, name); + const existsAlready = await fs.pathExists(dest); + + if (existsAlready) { + const alreadySymlink = await isSymlink(dest); + if (!alreadySymlink) { + // Real file/directory — consumer override, leave it alone. + skipped++; + continue; + } + // Already a symlink — idempotent, nothing to do. + skipped++; + continue; + } + + // Relative path from .agents/skills/ to ai/skills/ + const symlinkTarget = path.join("..", "..", AI_SKILLS_DIR, name); + await fs.symlink(symlinkTarget, dest); + created++; + } + + return { created, skipped, wouldCreate: [] }; +}; + +export { symlinkAgentsSkills }; diff --git a/lib/agents-skills-symlink.test.js b/lib/agents-skills-symlink.test.js new file mode 100644 index 0000000..01df092 --- /dev/null +++ b/lib/agents-skills-symlink.test.js @@ -0,0 +1,239 @@ +// @ts-check +/** @typedef {import("./agents-skills-symlink.js").SymlinkOptions} SymlinkOptions */ +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { symlinkAgentsSkills } from "./agents-skills-symlink.js"; + +describe("symlinkAgentsSkills", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join( + os.tmpdir(), + `aidd-agents-skills-symlink-${Date.now()}`, + ); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + /** @param {string[]} names */ + const createSkillDirs = async (names) => { + const skillsDir = path.join(tempDir, "ai", "skills"); + await fs.ensureDir(skillsDir); + for (const name of names) { + await fs.ensureDir(path.join(skillsDir, name)); + } + return skillsDir; + }; + + test("creates .agents/skills directory when it does not exist", async () => { + await createSkillDirs(["aidd-fix", "aidd-review"]); + + await symlinkAgentsSkills({ targetBase: tempDir }); + + const agentsSkillsDir = path.join(tempDir, ".agents", "skills"); + const exists = await fs.pathExists(agentsSkillsDir); + + assert({ + given: "a target without .agents/skills", + should: "create the .agents/skills directory", + actual: exists, + expected: true, + }); + }); + + test("creates symlinks for each aidd-* skill", async () => { + await createSkillDirs(["aidd-fix", "aidd-review", "aidd-tdd"]); + + await symlinkAgentsSkills({ targetBase: tempDir }); + + const results = await Promise.all( + ["aidd-fix", "aidd-review", "aidd-tdd"].map(async (name) => { + const symlinkPath = path.join(tempDir, ".agents", "skills", name); + const lstat = await fs.lstat(symlinkPath); + return lstat.isSymbolicLink(); + }), + ); + + assert({ + given: "aidd-* skill folders in ai/skills/", + should: "create a symlink for each at .agents/skills/", + actual: results.every(Boolean), + expected: true, + }); + }); + + test("symlink points to the correct relative target", async () => { + await createSkillDirs(["aidd-fix"]); + + await symlinkAgentsSkills({ targetBase: tempDir }); + + const symlinkPath = path.join(tempDir, ".agents", "skills", "aidd-fix"); + const linkTarget = await fs.readlink(symlinkPath); + + assert({ + given: "a created symlink", + should: "point to relative ../../ai/skills/aidd-fix", + actual: linkTarget, + expected: "../../ai/skills/aidd-fix", + }); + }); + + test("skips non-aidd-* folders in ai/skills/", async () => { + await createSkillDirs(["aidd-fix", "custom-skill", "my-tool"]); + + await symlinkAgentsSkills({ targetBase: tempDir }); + + const agentsSkillsDir = path.join(tempDir, ".agents", "skills"); + const entries = await fs.readdir(agentsSkillsDir); + + assert({ + given: "a mix of aidd-* and non-aidd-* skills", + should: "only create symlinks for aidd-* skills", + actual: entries.sort(), + expected: ["aidd-fix"], + }); + }); + + test("is idempotent when symlinks already exist", async () => { + await createSkillDirs(["aidd-fix"]); + + await symlinkAgentsSkills({ targetBase: tempDir }); + await symlinkAgentsSkills({ targetBase: tempDir }); + + const symlinkPath = path.join(tempDir, ".agents", "skills", "aidd-fix"); + const lstat = await fs.lstat(symlinkPath); + + assert({ + given: "a symlink already exists", + should: "still be a symlink after second run", + actual: lstat.isSymbolicLink(), + expected: true, + }); + }); + + test("does not overwrite real directories (non-symlinks)", async () => { + await createSkillDirs(["aidd-fix"]); + + // Pre-create a real directory to simulate consumer override + const overridePath = path.join(tempDir, ".agents", "skills", "aidd-fix"); + await fs.ensureDir(overridePath); + await fs.writeFile(path.join(overridePath, "override.md"), "# override"); + + await symlinkAgentsSkills({ targetBase: tempDir }); + + const lstat = await fs.lstat(overridePath); + + assert({ + given: "a real directory at .agents/skills/aidd-fix", + should: "not convert it to a symlink", + actual: lstat.isSymbolicLink(), + expected: false, + }); + + assert({ + given: "a real directory at .agents/skills/aidd-fix", + should: "leave the override file intact", + actual: await fs.pathExists(path.join(overridePath, "override.md")), + expected: true, + }); + }); + + test("does not overwrite real files (non-symlinks)", async () => { + await createSkillDirs(["aidd-fix"]); + + const overridePath = path.join(tempDir, ".agents", "skills", "aidd-fix"); + await fs.ensureDir(path.dirname(overridePath)); + await fs.writeFile(overridePath, "# overridden as file"); + + await symlinkAgentsSkills({ targetBase: tempDir }); + + const lstat = await fs.lstat(overridePath); + + assert({ + given: "a real file at .agents/skills/aidd-fix", + should: "not convert it to a symlink", + actual: lstat.isSymbolicLink(), + expected: false, + }); + }); + + test("returns result summary with created and skipped counts", async () => { + await createSkillDirs(["aidd-fix", "aidd-review"]); + + const realOverridePath = path.join( + tempDir, + ".agents", + "skills", + "aidd-fix", + ); + await fs.ensureDir(realOverridePath); + + const result = await symlinkAgentsSkills({ targetBase: tempDir }); + + assert({ + given: "one skill overridden and one new", + should: "report 1 created", + actual: result.created, + expected: 1, + }); + + assert({ + given: "one skill overridden and one new", + should: "report 1 skipped", + actual: result.skipped, + expected: 1, + }); + }); + + test("returns dry run result without creating files", async () => { + await createSkillDirs(["aidd-fix", "aidd-review"]); + + const result = await symlinkAgentsSkills({ + targetBase: tempDir, + dryRun: true, + }); + + const agentsSkillsDir = path.join(tempDir, ".agents", "skills"); + const exists = await fs.pathExists(agentsSkillsDir); + + assert({ + given: "dry run mode", + should: "not create .agents/skills directory", + actual: exists, + expected: false, + }); + + assert({ + given: "dry run mode", + should: "report what would be created", + actual: result.wouldCreate.length, + expected: 2, + }); + }); + + test("returns empty result when ai/skills does not exist", async () => { + const result = await symlinkAgentsSkills({ targetBase: tempDir }); + + assert({ + given: "a target without ai/skills", + should: "return 0 created", + actual: result.created, + expected: 0, + }); + + assert({ + given: "a target without ai/skills", + should: "return 0 skipped", + actual: result.skipped, + expected: 0, + }); + }); +}); diff --git a/lib/cli-core.js b/lib/cli-core.js index 8cfccd9..dc7e9ff 100644 --- a/lib/cli-core.js +++ b/lib/cli-core.js @@ -6,6 +6,7 @@ import { createError, errorCauses } from "error-causes"; import fs from "fs-extra"; import { setupAgents } from "./agents-setup.js"; +import { symlinkAgentsSkills } from "./agents-skills-symlink.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -249,6 +250,16 @@ const executeClone = async ({ const agentsMdStatus = agentsMdExists ? "exists (skipped)" : "new"; logger.gray(`📄 aidd-custom/AGENTS.md (${agentsMdStatus})`); + const { wouldCreate } = await symlinkAgentsSkills({ + dryRun: true, + targetBase: paths.targetBase, + }); + for (const name of wouldCreate) { + logger.gray( + `🔗 .agents/skills/${name} → ../../ai/skills/${name} (symlink)`, + ); + } + return { dryRun: true, success: true }; } diff --git a/tasks/agents-skills-symlink-epic.md b/tasks/agents-skills-symlink-epic.md new file mode 100644 index 0000000..a178068 --- /dev/null +++ b/tasks/agents-skills-symlink-epic.md @@ -0,0 +1,24 @@ +# Agents Skills Symlink Epic + +**Status**: 🚧 IN PROGRESS +**Goal**: Symlink all aidd-* skills individually into `.agents/skills/` during installation so the AIDD framework plays nicely with the emerging cross-agent `.agents/` standard. + +## Overview + +Why: The `.agents/` folder is becoming the cross-agent standard for storing skills (see [cursor.com/docs/skills](https://cursor.com/docs/skills#skill-directories)). Without symlinking, consumers of the AIDD framework miss out on tool integration with agents that discover skills via `.agents/skills/`. By symlinking `ai/skills/aidd-*` into `.agents/skills/aidd-*` at install time, aidd installs become discoverable by any agent that follows the standard — without requiring consumers to manage two copies of the skill files. + +--- + +## Create .agents/skills Symlinks + +Adds a symlink step to the installation pipeline that creates `.agents/skills/aidd-*` symlinks pointing to `ai/skills/aidd-*`. + +**Requirements**: +- Given a target directory with `ai/skills/aidd-*` folders, should create `.agents/skills/` directory if it does not exist +- Given an `aidd-*` skill folder in `ai/skills/`, should create a symlink at `.agents/skills/` pointing to the relative path `../../ai/skills/` +- Given an `.agents/skills/aidd-*` entry that is already a symlink, should skip (idempotent — do not recreate or error) +- Given an `.agents/skills/aidd-*` entry that is a real directory or file (not a symlink), should leave it untouched so consumers can override/disable individual skills +- Given a dry run, should report what symlinks would be created without modifying the filesystem +- Given verbose mode, should log each symlink operation result + +---