Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions lib/agents-index-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>",
actual: linkTarget,
expected: `../../ai/skills/${firstSkill}`,
});
});
});
8 changes: 8 additions & 0 deletions lib/agents-setup.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from "path";

import { ensureAgentsMd } from "./agents-md.js";
import { symlinkAgentsSkills } from "./agents-skills-symlink.js";
import {
createAiddCustomAgentsMd,
createAiddCustomConfig,
Expand All @@ -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 };
16 changes: 16 additions & 0 deletions lib/agents-setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
});
});
});
121 changes: 121 additions & 0 deletions lib/agents-skills-symlink.js
Original file line number Diff line number Diff line change
@@ -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/<name> → ../../ai/skills/<name>.
* - 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/<name> to ai/skills/<name>
const symlinkTarget = path.join("..", "..", AI_SKILLS_DIR, name);
await fs.symlink(symlinkTarget, dest);
created++;
}

return { created, skipped, wouldCreate: [] };
};

export { symlinkAgentsSkills };
Loading
Loading