From 95da89611a232f3b82f72fdae0e2edbe4be75a3a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:33:20 +0000 Subject: [PATCH 01/12] fix(index-generator): use optional chaining to simplify frontmatter rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove verbose if/else block in generateFileEntry — optional chaining handles the absent-frontmatter case cleanly without a placeholder string. Update the corresponding test expectation to match. --- lib/index-generator.js | 22 +++++++++------------- lib/index-generator.test.js | 6 ++++-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/index-generator.js b/lib/index-generator.js index 7399ccaa..6b3a329f 100644 --- a/lib/index-generator.js +++ b/lib/index-generator.js @@ -122,19 +122,15 @@ const generateFileEntry = async (dirPath, filename) => { let entry = `### ${escapeMarkdownLink(title)}\n\n`; entry += `**File:** \`${escapedFilename}\`\n\n`; - if (frontmatter) { - if (frontmatter.description) { - entry += `${frontmatter.description}\n\n`; - } - if (frontmatter.globs) { - entry += `**Applies to:** \`${frontmatter.globs}\`\n\n`; - } - if (frontmatter.alwaysApply === true) { - entry += `**Always active**\n\n`; - } - } else { - // No frontmatter - just note the file exists - entry += `*No description available*\n\n`; + if (frontmatter?.description) { + entry += `${frontmatter.description}\n\n`; + } + + if (frontmatter?.globs) { + entry += `**Applies to:** \`${frontmatter.globs}\`\n\n`; + } + if (frontmatter?.alwaysApply === true) { + entry += `**Always active**\n\n`; } return entry; diff --git a/lib/index-generator.test.js b/lib/index-generator.test.js index 8de96d83..5e0aa0ac 100644 --- a/lib/index-generator.test.js +++ b/lib/index-generator.test.js @@ -203,8 +203,10 @@ Content here.`; assert({ given: "file without frontmatter", - should: "note no description available", - actual: content.includes("No description available"), + should: "include the file title without a description placeholder", + actual: + content.includes("Plain File") && + !content.includes("No description available"), expected: true, }); }); From b824b57299a73719d5d9b75165563e9edd80faa5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:43:28 +0000 Subject: [PATCH 02/12] feat(agents-md): add CLAUDE.md support, syncRootAgentFiles, and testing directive - Add test:e2e to requiredDirectives and directiveAppendSections so agents know to run E2E tests before committing - Add Testing section to agentsMdContent template - Add ensureClaudeMd: creates or updates CLAUDE.md with agent guidelines - Add syncRootAgentFiles: keeps AGENTS.md and CLAUDE.md in sync with the current template (used by the pre-commit hook) - Add type declarations (agents-md.d.ts) for all new public exports - Update tests to cover new functions and the test:e2e directive --- lib/agents-index-e2e.test.js | 1 + lib/agents-md.d.ts | 47 +++++++-- lib/agents-md.js | 136 +++++++++++++++++++++++-- lib/agents-md.test.js | 186 +++++++++++++++++++++++++++++++++++ 4 files changed, 353 insertions(+), 17 deletions(-) diff --git a/lib/agents-index-e2e.test.js b/lib/agents-index-e2e.test.js index 91a9c9f6..6d894732 100644 --- a/lib/agents-index-e2e.test.js +++ b/lib/agents-index-e2e.test.js @@ -67,6 +67,7 @@ Read index.md files (auto-generated from frontmatter). Only use root index until needed. Always read the vision document first. Report conflict resolution to the user. +Run npm run test:e2e before committing. Check aidd-custom/ for project-specific skills and configuration. `; await fs.writeFile(agentsPath, customContent); diff --git a/lib/agents-md.d.ts b/lib/agents-md.d.ts index e2aa0c85..43ad3bfb 100644 --- a/lib/agents-md.d.ts +++ b/lib/agents-md.d.ts @@ -2,7 +2,7 @@ * AGENTS.md file management for AI agent directives */ -/** Result of ensureAgentsMd operation */ +/** Result of ensureAgentsMd or ensureClaudeMd operation */ export interface AgentsMdResult { /** Action taken: "created", "appended", or "unchanged" */ action: "created" | "appended" | "unchanged"; @@ -81,17 +81,44 @@ export function appendDirectives( ): Promise; /** - * Ensure AGENTS.md exists with required directives - * - * - If AGENTS.md does not exist, creates it with standard content - * - If AGENTS.md exists but missing directives, appends them - * - If AGENTS.md exists with all directives, does nothing + * Ensure AGENTS.md exists with required directives. + * - If AGENTS.md does not exist, creates it with standard content. + * - If AGENTS.md exists but is missing directives, appends them. + * - If AGENTS.md exists with all directives, does nothing. * * @param targetBase - Base directory for AGENTS.md * @returns Result indicating action taken - * - * @example - * const result = await ensureAgentsMd('/path/to/project'); - * // result.action: "created" | "appended" | "unchanged" */ export function ensureAgentsMd(targetBase: string): Promise; + +/** + * Ensure CLAUDE.md exists and references AGENTS.md. + * - Not present → create with agentsMdContent. + * - Present but missing AGENTS.md reference and incomplete directives → append pointer. + * - Present with all directives or AGENTS.md reference → leave unchanged. + * + * @param targetBase - Base directory for CLAUDE.md + * @returns Result indicating action taken + */ +export function ensureClaudeMd(targetBase: string): Promise; + +/** Result of a single file sync in syncRootAgentFiles */ +export interface SyncFileResult { + /** Filename that was processed (e.g. "AGENTS.md" or "CLAUDE.md") */ + file: string; + /** Action taken: "created", "updated", or "unchanged" */ + action: "created" | "updated" | "unchanged"; +} + +/** + * Overwrite AGENTS.md and CLAUDE.md with the current agentsMdContent template + * if either file is missing or its content differs from the template. + * Used by the pre-commit hook to keep committed copies in sync whenever + * agentsMdContent is updated. + * + * @param targetBase - Base directory containing AGENTS.md and CLAUDE.md + * @returns Array of results for each file processed + */ +export function syncRootAgentFiles( + targetBase: string, +): Promise; diff --git a/lib/agents-md.js b/lib/agents-md.js index a61e325f..75d40fe6 100644 --- a/lib/agents-md.js +++ b/lib/agents-md.js @@ -20,6 +20,7 @@ const requiredDirectives = [ "vision", // Vision document requirement "conflict", // Conflict resolution "generated", // Auto-generated files warning + "test:e2e", // E2E test instruction before committing ]; // The content for AGENTS.md @@ -72,6 +73,22 @@ Never proceed with a task that contradicts the vision without explicit user appr Project-specific customization lives in \`aidd-custom/\`. Before starting work, read \`aidd-custom/index.md\` to discover available project-specific skills, and read \`aidd-custom/config.yml\` to load configuration into context. + +## Task Index + +fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (\`npm run test:unit\`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +\`\`\`sh +npm run test:e2e +\`\`\` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. `; /** @@ -181,6 +198,16 @@ read \`aidd-custom/index.md\` to discover available project-specific skills, and read \`aidd-custom/config.yml\` to load configuration into context.`, keywords: ["aidd-custom"], }, + { + content: `### Testing + +The pre-commit hook runs unit tests only (\`npm run test:unit\`). Run the full E2E suite manually before committing: + +\`\`\`sh +npm run test:e2e +\`\`\``, + keywords: ["test:e2e"], + }, ]; /** @@ -248,15 +275,110 @@ const ensureAgentsMd = async (targetBase) => { }; }; +/** + * Ensure CLAUDE.md exists and references AGENTS.md. + * - Not present → create with agentsMdContent. + * - Present but missing AGENTS.md reference and incomplete directives → append pointer. + * - Present with all directives or AGENTS.md reference → leave unchanged. + */ +const ensureClaudeMd = async (targetBase) => { + const claudePath = path.join(targetBase, "CLAUDE.md"); + const exists = await fs.pathExists(claudePath); + + if (!exists) { + try { + await fs.writeFile(claudePath, agentsMdContent, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to write CLAUDE.md: ${claudePath}`, + }); + } + return { + action: "created", + message: "Created CLAUDE.md with AI agent guidelines", + }; + } + + let existingContent; + try { + existingContent = await fs.readFile(claudePath, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to read CLAUDE.md: ${claudePath}`, + }); + } + + // Treat the file as complete when it already has the full agent directives + // (e.g. created by a previous install) OR explicitly references AGENTS.md. + if ( + hasAllDirectives(existingContent) || + existingContent.includes("AGENTS.md") + ) { + return { + action: "unchanged", + message: "CLAUDE.md already contains agent guidelines", + }; + } + + const appendLine = + "\n\n> **AI agents**: see [AGENTS.md](./AGENTS.md) for project-specific agent guidelines.\n"; + try { + await fs.writeFile(claudePath, existingContent + appendLine, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to append to CLAUDE.md: ${claudePath}`, + }); + } + return { + action: "appended", + message: "Appended AGENTS.md reference to existing CLAUDE.md", + }; +}; + +/** + * Overwrite AGENTS.md and CLAUDE.md with the current agentsMdContent template + * if either file is missing or its content differs from the template. + * Used by the pre-commit hook to keep committed copies in sync whenever + * agentsMdContent is updated, exactly as ai\/**\/index.md files are regenerated. + * Returns an array of { file, action } objects. + */ +const syncRootAgentFiles = async (targetBase) => { + const files = ["AGENTS.md", "CLAUDE.md"]; + return Promise.all( + files.map(async (filename) => { + const filePath = path.join(targetBase, filename); + const exists = await fs.pathExists(filePath); + if (!exists) { + await fs.writeFile(filePath, agentsMdContent, "utf-8"); + return { action: "created", file: filename }; + } + const existing = await fs.readFile(filePath, "utf-8"); + if (existing === agentsMdContent) { + return { action: "unchanged", file: filename }; + } + await fs.writeFile(filePath, agentsMdContent, "utf-8"); + return { action: "updated", file: filename }; + }), + ); +}; + export { - ensureAgentsMd, agentsFileExists, - readAgentsFile, - hasAllDirectives, - getMissingDirectives, - writeAgentsFile, - appendDirectives, agentsMdContent, - requiredDirectives, + appendDirectives, directiveAppendSections, + ensureAgentsMd, + ensureClaudeMd, + getMissingDirectives, + hasAllDirectives, + readAgentsFile, + requiredDirectives, + syncRootAgentFiles, + writeAgentsFile, }; diff --git a/lib/agents-md.test.js b/lib/agents-md.test.js index b33c8131..5d2d9b9b 100644 --- a/lib/agents-md.test.js +++ b/lib/agents-md.test.js @@ -11,11 +11,15 @@ import { appendDirectives, directiveAppendSections, ensureAgentsMd, + ensureClaudeMd, getMissingDirectives, hasAllDirectives, requiredDirectives, + syncRootAgentFiles, } from "./agents-md.js"; +/** @typedef {import('./agents-md.js').SyncFileResult} SyncFileResult */ + describe("agents-md", () => { let tempDir = ""; @@ -58,6 +62,7 @@ describe("agents-md", () => { Root Index from base VISION document requirement CONFLICT resolution + Run NPM RUN TEST:E2E before committing AIDD-CUSTOM folder customization `; @@ -291,6 +296,7 @@ Read index.md files (auto-generated). Only use root index until you need more. Always read the vision document first. Report conflict resolution to the user. +Run npm run test:e2e before committing. Check aidd-custom/ for project-specific skills and configuration. `; await fs.writeFile(path.join(tempDir, "AGENTS.md"), customContent); @@ -363,6 +369,14 @@ Check aidd-custom/ for project-specific skills and configuration. actual: requiredDirectives.includes("aidd-custom"), expected: true, }); + + assert({ + given: "requiredDirectives constant", + should: + "include test:e2e directive so agents know to run E2E tests before committing", + actual: requiredDirectives.includes("test:e2e"), + expected: true, + }); }); }); @@ -443,5 +457,177 @@ Check aidd-custom/ for project-specific skills and configuration. expected: true, }); }); + + test("includes the e2e test directive keyword", () => { + const testingSection = directiveAppendSections.find(({ keywords }) => + keywords.includes("test:e2e"), + ); + + assert({ + given: "directiveAppendSections", + should: "include a section with test:e2e keyword", + actual: testingSection !== undefined, + expected: true, + }); + }); + }); + + describe("agentsMdContent e2e instruction", () => { + test("instructs agents to run npm run test:e2e before committing", () => { + assert({ + given: "agentsMdContent", + should: "include the npm run test:e2e command", + actual: agentsMdContent.includes("npm run test:e2e"), + expected: true, + }); + }); + + test("explains that E2E tests are excluded from the pre-commit hook", () => { + assert({ + given: "agentsMdContent", + should: "explain why E2E tests do not run in the pre-commit hook", + actual: agentsMdContent.includes("pre-commit hook"), + expected: true, + }); + }); + }); +}); + +describe("syncRootAgentFiles", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-sync-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("creates both files when neither exists", async () => { + const results = await syncRootAgentFiles(tempDir); + + assert({ + given: "neither AGENTS.md nor CLAUDE.md exists", + should: "create both files", + actual: results.map(({ action }) => action).every((a) => a === "created"), + expected: true, + }); + + assert({ + given: "files just created", + should: "write agentsMdContent to both", + actual: await fs.readFile(path.join(tempDir, "AGENTS.md"), "utf-8"), + expected: agentsMdContent, + }); + }); + + test("returns unchanged when both files already match the template", async () => { + await fs.writeFile( + path.join(tempDir, "AGENTS.md"), + agentsMdContent, + "utf-8", + ); + await fs.writeFile( + path.join(tempDir, "CLAUDE.md"), + agentsMdContent, + "utf-8", + ); + + const results = await syncRootAgentFiles(tempDir); + + assert({ + given: "both files already match the current template", + should: "report unchanged for both", + actual: results.every(({ action }) => action === "unchanged"), + expected: true, + }); + }); + + test("overwrites a stale file whose content differs from the current template", async () => { + await fs.writeFile( + path.join(tempDir, "AGENTS.md"), + "# Old content", + "utf-8", + ); + await fs.writeFile( + path.join(tempDir, "CLAUDE.md"), + agentsMdContent, + "utf-8", + ); + + const results = await syncRootAgentFiles(tempDir); + const agentsResult = results.find(({ file }) => file === "AGENTS.md"); + + assert({ + given: "AGENTS.md has stale content", + should: "report updated action", + actual: agentsResult?.action, + expected: "updated", + }); + + assert({ + given: "AGENTS.md updated", + should: "now contain the current template", + actual: await fs.readFile(path.join(tempDir, "AGENTS.md"), "utf-8"), + expected: agentsMdContent, + }); + }); +}); + +describe("ensureClaudeMd", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-claude-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("creates CLAUDE.md with agentsMdContent when no CLAUDE.md exists", async () => { + const result = await ensureClaudeMd(tempDir); + + assert({ + given: "no CLAUDE.md exists", + should: "report created action", + actual: result.action, + expected: "created", + }); + + assert({ + given: "CLAUDE.md just created", + should: "contain the agentsMdContent template", + actual: await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"), + expected: agentsMdContent, + }); + }); + + test("appends AGENTS.md reference when CLAUDE.md exists without it", async () => { + await fs.writeFile( + path.join(tempDir, "CLAUDE.md"), + "# My CLAUDE.md\n\nCustom content.", + "utf-8", + ); + + const result = await ensureClaudeMd(tempDir); + + assert({ + given: "CLAUDE.md without AGENTS.md reference or directives", + should: "report appended action", + actual: result.action, + expected: "appended", + }); + + const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); + assert({ + given: "CLAUDE.md after appending", + should: "contain a reference to AGENTS.md", + actual: content.includes("AGENTS.md"), + expected: true, + }); }); }); From 8379b8664c24175fb185463dd04447b5fbb23c8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:43:49 +0000 Subject: [PATCH 03/12] feat(cli): extract createSymlink module and add --claude flag - Extract lib/symlinks.js: generic createSymlink({ name, targetBase, force }) replacing the inline createCursorSymlink in cli-core.js; handles both .cursor and .claude (and any future editor symlink) uniformly - Add --claude flag to the main command for Claude Code integration - executeClone now accepts a claude option and creates the .claude symlink when requested, mirroring the existing cursor option - executeClone calls ensureClaudeMd so CLAUDE.md is always written on install - executeClone catch now returns the original Error instance rather than a plain object, letting bin/aidd.js pass it directly to handleCliErrors() - --index handler now calls syncRootAgentFiles after index generation so AGENTS.md and CLAUDE.md stay current every time the pre-commit hook runs - Update help text to document --claude and the combined --cursor --claude usage - Add engines.node: >=18 to package.json for explicit Node version requirement - Add tests: createSymlink error dispatch, .claude symlink via executeClone, error-result shape (instanceof Error), engines.node declaration --- bin/aidd.js | 44 +++++--- bin/cli-help-e2e.test.js | 5 +- lib/cli-core.d.ts | 7 +- lib/cli-core.js | 86 +++++----------- lib/cli-core.test.js | 39 +++++++ lib/exports.test.js | 13 +++ lib/symlinks.js | 60 +++++++++++ lib/symlinks.test.js | 212 +++++++++++++++++++++++++++++++++++++++ package.json | 3 + 9 files changed, 387 insertions(+), 82 deletions(-) create mode 100644 lib/symlinks.js create mode 100644 lib/symlinks.test.js diff --git a/bin/aidd.js b/bin/aidd.js index d9a00acd..ecd790b4 100755 --- a/bin/aidd.js +++ b/bin/aidd.js @@ -7,6 +7,7 @@ import { fileURLToPath } from "url"; import chalk from "chalk"; import { Command } from "commander"; +import { syncRootAgentFiles } from "../lib/agents-md.js"; import { executeClone, handleCliErrors } from "../lib/cli-core.js"; import { generateAllIndexes } from "../lib/index-generator.js"; @@ -35,6 +36,7 @@ const createCli = () => { "-c, --cursor", "create .cursor symlink for Cursor editor integration", ) + .option("--claude", "create .claude symlink for Claude Code integration") .option( "-i, --index", "generate index.md files from frontmatter in ai/ subfolders", @@ -85,7 +87,15 @@ To install for Cursor: npx aidd --cursor -Install without Cursor integration: +To install for Claude Code: + + npx aidd --claude + +To install for both: + + npx aidd --cursor --claude + +Install without editor integration: npx aidd my-project `, @@ -99,7 +109,10 @@ https://paralleldrive.com `, ) .action( - async (targetDirectory, { force, dryRun, verbose, cursor, index }) => { + async ( + targetDirectory, + { force, dryRun, verbose, cursor, claude, index }, + ) => { // Handle --index option separately if (index) { const targetPath = path.resolve(process.cwd(), targetDirectory); @@ -125,15 +138,24 @@ https://paralleldrive.com console.log(chalk.gray(` - ${idx.path}`)); }); } - process.exit(0); } else { console.error(chalk.red(`❌ ${result.error.message}`)); process.exit(1); } + + const syncResults = await syncRootAgentFiles(targetPath); + syncResults + .filter(({ action }) => action !== "unchanged") + .forEach(({ file, action }) => { + console.log(chalk.green(`✅ ${action} ${file}`)); + }); + + process.exit(0); return; } const result = await executeClone({ + claude, cursor, dryRun, force, @@ -142,14 +164,6 @@ https://paralleldrive.com }); if (!result.success) { - // Create a proper error with cause for handleErrors - const error = new Error(result.error.message, { - cause: result.error.cause || { - code: result.error.code || "UNEXPECTED_ERROR", - }, - }); - - // Use handleErrors instead of manual switching try { handleCliErrors({ CloneError: ({ message, cause }) => { @@ -176,11 +190,11 @@ https://paralleldrive.com "💡 Try using --force to overwrite existing files", ); }, - })(error); + })(result.error); } catch { - // Fallback for unexpected errors - console.error(`❌ Unexpected Error: ${result.error.message}`); - if (verbose && result.error.cause) { + // Fallback for unexpected errors (e.g. an error without a cause code) + console.error(`❌ Unexpected Error: ${result.error?.message}`); + if (verbose && result.error?.cause) { console.error("🔍 Caused by:", result.error.cause); } } diff --git a/bin/cli-help-e2e.test.js b/bin/cli-help-e2e.test.js index 3029c8e7..843334f5 100644 --- a/bin/cli-help-e2e.test.js +++ b/bin/cli-help-e2e.test.js @@ -147,11 +147,12 @@ describe("CLI help command", () => { assert({ given: "CLI help command is run", - should: "show Quick Start section with README format", + should: "show Quick Start section with Cursor and Claude Code options", actual: stdout.includes("Quick Start") && stdout.includes("To install for Cursor:") && - stdout.includes("Install without Cursor integration:"), + stdout.includes("To install for Claude Code:") && + stdout.includes("Install without editor integration:"), expected: true, }); }); diff --git a/lib/cli-core.d.ts b/lib/cli-core.d.ts index 7c675066..85ad4100 100644 --- a/lib/cli-core.d.ts +++ b/lib/cli-core.d.ts @@ -22,18 +22,16 @@ export interface ResolvedPaths { } /** Error detail shape when executeClone fails */ -export interface ExecuteCloneError { +export interface ExecuteCloneError extends Error { cause?: unknown; code?: string; - message: string; - type?: string; } /** Result returned by executeClone — covers success and error shapes */ export type ExecuteCloneResult = | { paths: ResolvedPaths; success: true } | { dryRun: true; success: true } - | { error: ExecuteCloneError; success: false }; + | { error: Error; success: false }; /** * Create aidd-custom/config.yml with default project settings @@ -90,6 +88,7 @@ export function executeClone(options?: { dryRun?: boolean; verbose?: boolean; cursor?: boolean; + claude?: boolean; }): Promise; /** diff --git a/lib/cli-core.js b/lib/cli-core.js index f58eeae3..8c58989c 100644 --- a/lib/cli-core.js +++ b/lib/cli-core.js @@ -5,8 +5,9 @@ import chalk from "chalk"; import { createError, errorCauses } from "error-causes"; import fs from "fs-extra"; -import { ensureAgentsMd } from "./agents-md.js"; +import { ensureAgentsMd, ensureClaudeMd } from "./agents-md.js"; import { generateIndexRecursive } from "./index-generator.js"; +import { createSymlink } from "./symlinks.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -160,43 +161,6 @@ const createAiddCustomConfig = } }; -const createCursorSymlink = - ({ targetBase, force = false }) => - async () => { - const cursorPath = path.join(targetBase, ".cursor"); - const aiRelativePath = "ai"; - - try { - // Check if .cursor already exists - const cursorExists = await fs.pathExists(cursorPath); - - if (cursorExists) { - if (!force) { - throw createError({ - ...ValidationError, - message: ".cursor already exists (use --force to overwrite)", - }); - } - // Remove existing .cursor (file or symlink) - await fs.remove(cursorPath); - } - - // Create symlink - await fs.symlink(aiRelativePath, cursorPath); - } catch (originalError) { - // If it's already our validation error, re-throw - if (originalError.cause?.code === "VALIDATION_ERROR") { - throw originalError; - } - - throw createError({ - ...FileSystemError, - cause: originalError, - message: `Failed to create .cursor symlink: ${originalError.message}`, - }); - } - }; - // Output functions const createLogger = ({ verbose = false, dryRun = false } = {}) => ({ cyan: (msg) => console.log(chalk.cyan(msg)), @@ -246,6 +210,7 @@ const executeClone = async ({ dryRun = false, verbose = false, cursor = false, + claude = false, } = {}) => { try { const logger = createLogger({ dryRun, verbose }); @@ -288,6 +253,11 @@ const executeClone = async ({ const agentsResult = await ensureAgentsMd(paths.targetBase); verbose && logger.verbose(`AGENTS.md: ${agentsResult.message}`); + // Ensure CLAUDE.md exists (Claude Code reads this for project guidelines). + verbose && logger.info("Setting up CLAUDE.md..."); + const claudeResult = await ensureClaudeMd(paths.targetBase); + verbose && logger.verbose(`CLAUDE.md: ${claudeResult.message}`); + // Create aidd-custom/config.yml with project defaults. // Never force-overwrite: config.yml holds user preferences, not framework code. verbose && logger.info("Creating aidd-custom/config.yml..."); @@ -302,10 +272,22 @@ const executeClone = async ({ const customDir = path.join(paths.targetBase, "aidd-custom"); await generateIndexRecursive(customDir); - // Create cursor symlink if requested + // Create editor symlinks if requested if (cursor) { verbose && logger.info("Creating .cursor symlink..."); - await createCursorSymlink({ force, targetBase: paths.targetBase })(); + await createSymlink({ + force, + name: ".cursor", + targetBase: paths.targetBase, + })(); + } + if (claude) { + verbose && logger.info("Creating .claude symlink..."); + await createSymlink({ + force, + name: ".claude", + targetBase: paths.targetBase, + })(); } // Success output @@ -313,27 +295,9 @@ const executeClone = async ({ return { paths, success: true }; } catch (error) { - // Structured error handling using error causes - if (error.cause) { - return { - error: { - cause: error.cause, - code: error.cause?.code, - message: error.message, - }, - success: false, - }; - } - - // Handle unexpected errors - return { - error: { - cause: error.cause, - message: error.message, - type: "unexpected", - }, - success: false, - }; + // Return the original Error object so callers can pass it directly to + // handleCliErrors() without reconstructing a wrapper Error. + return { error, success: false }; } }; diff --git a/lib/cli-core.test.js b/lib/cli-core.test.js index f9ebe080..09471988 100644 --- a/lib/cli-core.test.js +++ b/lib/cli-core.test.js @@ -204,3 +204,42 @@ describe("executeClone dry-run config.yml status", () => { }); }); }); + +describe("executeClone error result shape", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-cli-core-test-${Date.now()}`); + await fs.ensureDir(tempDir); + // Pre-create an ai/ folder so the ai/ already-exists validation fires + await fs.ensureDir(path.join(tempDir, "ai")); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("returns result.error as an Error instance when clone fails", async () => { + /** @type {any} */ + const result = await executeClone({ targetDirectory: tempDir }); + + assert({ + given: "a clone that fails validation", + should: "return result.error as an instanceof Error", + actual: result.success === false && result.error instanceof Error, + expected: true, + }); + }); + + test("result.error has a cause.code identifying the error type", async () => { + /** @type {any} */ + const result = await executeClone({ targetDirectory: tempDir }); + + assert({ + given: "a clone that fails with a validation error", + should: "have result.error.cause.code equal to VALIDATION_ERROR", + actual: result.error?.cause?.code, + expected: "VALIDATION_ERROR", + }); + }); +}); diff --git a/lib/exports.test.js b/lib/exports.test.js index 66bb008e..65e2efe8 100644 --- a/lib/exports.test.js +++ b/lib/exports.test.js @@ -127,4 +127,17 @@ describe("package.json exports configuration", () => { expected: true, }); }); + + test("declares Node.js engine requirement", async () => { + const pkg = await import("../package.json", { with: { type: "json" } }); + const nodeEngine = pkg.default.engines?.node; + + assert({ + given: "a user on Node < 18", + should: + "declare engines.node to surface a clear version error at install time", + actual: nodeEngine, + expected: ">=18", + }); + }); }); diff --git a/lib/symlinks.js b/lib/symlinks.js new file mode 100644 index 00000000..efdf4696 --- /dev/null +++ b/lib/symlinks.js @@ -0,0 +1,60 @@ +import path from "path"; +import { createError } from "error-causes"; +import fs from "fs-extra"; + +// Reuse the same name/code strings as cli-core.js so handleCliErrors dispatches correctly. +const ValidationError = { + code: "VALIDATION_ERROR", + message: "Input validation failed", + name: "ValidationError", +}; +const FileSystemError = { + code: "FILESYSTEM_ERROR", + message: "File system operation failed", + name: "FileSystemError", +}; + +/** + * Create a symlink at `/` pointing to the relative path `ai`. + * Handles `.cursor`, `.claude`, or any future editor symlink. + * + * Returns an async thunk so call-sites compose it with other thunks: + * await createSymlink({ name: '.claude', targetBase, force })() + * + * @param {{ name: string, targetBase: string, force?: boolean }} options + */ +const createSymlink = + ({ name, targetBase, force = false }) => + async () => { + const symlinkPath = path.join(targetBase, name); + const aiRelativePath = "ai"; + + try { + const exists = await fs.pathExists(symlinkPath); + + if (exists) { + if (!force) { + throw createError({ + ...ValidationError, + message: `${name} already exists (use --force to overwrite)`, + }); + } + await fs.remove(symlinkPath); + } + + await fs.symlink(aiRelativePath, symlinkPath); + } catch (/** @type {any} */ originalError) { + // Validation errors are already structured — re-throw as-is. + if (originalError.cause?.code === "VALIDATION_ERROR") { + throw originalError; + } + + throw createError({ + ...FileSystemError, + cause: originalError, + message: `Failed to create ${name} symlink: ${originalError.message}`, + }); + } + }; + +export { createSymlink }; diff --git a/lib/symlinks.test.js b/lib/symlinks.test.js new file mode 100644 index 00000000..4f2a665a --- /dev/null +++ b/lib/symlinks.test.js @@ -0,0 +1,212 @@ +// @ts-check +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { executeClone } from "./cli-core.js"; +import { createSymlink } from "./symlinks.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe("createSymlink error dispatch", () => { + const tempTestDir = path.join(__dirname, "temp-symlink-errors-test"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("existing target without force should set cause.name to ValidationError", async () => { + await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); + + try { + await createSymlink({ + name: ".cursor", + targetBase: tempTestDir, + force: false, + })(); + assert({ + given: "a symlink target that already exists and force is false", + should: "throw an error", + actual: false, + expected: true, + }); + } catch (e) { + const error = /** @type {any} */ (e); + assert({ + given: "a symlink target that already exists and force is false", + should: + "set cause.name to ValidationError for handleCliErrors dispatch", + actual: error.cause.name, + expected: "ValidationError", + }); + } + }); + + test("existing target without force should set cause.code to VALIDATION_ERROR", async () => { + await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); + + try { + await createSymlink({ + name: ".cursor", + targetBase: tempTestDir, + force: false, + })(); + assert({ + given: "a symlink target that already exists and force is false", + should: "throw an error", + actual: false, + expected: true, + }); + } catch (e) { + const error = /** @type {any} */ (e); + assert({ + given: "a symlink target that already exists and force is false", + should: "set cause.code to VALIDATION_ERROR", + actual: error.cause.code, + expected: "VALIDATION_ERROR", + }); + } + }); +}); + +describe("claude symlink functionality", () => { + const tempTestDir = path.join(__dirname, "temp-claude-test"); + const claudePath = path.join(tempTestDir, ".claude"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("claude option false should not create symlink", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: false }); + + assert({ + given: "claude option is false", + should: "not create .claude symlink", + actual: await fs.pathExists(claudePath), + expected: false, + }); + }); + + test("claude option true should create symlink to ai folder", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: true }); + + assert({ + given: "claude option is true", + should: "create .claude symlink", + actual: await fs.pathExists(claudePath), + expected: true, + }); + }); + + test("created .claude symlink should point to ai folder", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: true }); + + const symlinkStat = await fs.lstat(claudePath); + const symlinkTarget = await fs.readlink(claudePath); + + assert({ + given: ".claude symlink is created", + should: "be a symbolic link", + actual: symlinkStat.isSymbolicLink(), + expected: true, + }); + + assert({ + given: ".claude symlink is created", + should: "point to ai folder", + actual: symlinkTarget, + expected: "ai", + }); + }); + + test("dry run with claude should not create symlink", async () => { + const result = await executeClone({ + targetDirectory: tempTestDir, + claude: true, + dryRun: true, + }); + + assert({ + given: "dry run mode with claude option", + should: "not actually create symlink", + actual: await fs.pathExists(claudePath), + expected: false, + }); + + assert({ + given: "dry run mode with claude option", + should: "indicate success", + actual: result.success, + expected: true, + }); + }); + + test("existing .claude file should be replaced with force", async () => { + await fs.writeFile(claudePath, "existing content"); + + await executeClone({ + targetDirectory: tempTestDir, + claude: true, + force: true, + }); + + assert({ + given: "existing .claude file with force option", + should: "replace with symlink", + actual: (await fs.lstat(claudePath)).isSymbolicLink(), + expected: true, + }); + }); +}); + +describe("cursor and claude symlinks together", () => { + const tempTestDir = path.join(__dirname, "temp-both-symlinks-test"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("cursor and claude both true should create both symlinks", async () => { + await executeClone({ + targetDirectory: tempTestDir, + cursor: true, + claude: true, + }); + + const cursorStat = await fs.lstat(path.join(tempTestDir, ".cursor")); + const claudeStat = await fs.lstat(path.join(tempTestDir, ".claude")); + + assert({ + given: "both cursor and claude options are true", + should: "create .cursor symlink", + actual: cursorStat.isSymbolicLink(), + expected: true, + }); + + assert({ + given: "both cursor and claude options are true", + should: "create .claude symlink", + actual: claudeStat.isSymbolicLink(), + expected: true, + }); + }); +}); diff --git a/package.json b/package.json index 1d6ff4c2..365f84fc 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "author": "ParallelDrive", + "engines": { + "node": ">=18" + }, "bin": { "aidd": "bin/aidd.js" }, From 872749dd90305a0549090826003018c07bf5d6aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:45:00 +0000 Subject: [PATCH 04/12] feat(pre-commit): sync AGENTS.md and CLAUDE.md on every commit - Pre-commit hook now stages AGENTS.md and CLAUDE.md after running `npx aidd --index`, which calls syncRootAgentFiles internally - This ensures the committed copies stay in sync whenever agentsMdContent is updated, exactly as ai/**/index.md files are regenerated on each commit - Add Testing section to AGENTS.md instructing agents to run test:e2e before committing - Add CLAUDE.md (mirrors AGENTS.md) for Claude Code agent guidelines --- .husky/pre-commit | 6 ++--- AGENTS.md | 12 +++++++++ CLAUDE.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md diff --git a/.husky/pre-commit b/.husky/pre-commit index e03f6257..62d316e8 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,8 +1,8 @@ -# Generate index.md files for ai/ and aidd-custom/ folders +# Generate index.md files for ai/ and aidd-custom/ folders, and sync AGENTS.md / CLAUDE.md node bin/aidd.js --index "$(git rev-parse --show-toplevel)" -# Stage any updated index.md files -git add 'ai/**/index.md' 'aidd-custom/**/index.md' 2>/dev/null || true +# Stage any updated index.md files and synced agent files +git add 'ai/**/index.md' 'aidd-custom/**/index.md' AGENTS.md CLAUDE.md 2>/dev/null || true # Generate ToC for README.md npm run toc diff --git a/AGENTS.md b/AGENTS.md index 8f170e40..01eb8d60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,3 +51,15 @@ and read `aidd-custom/config.yml` to load configuration into context. ## Task Index fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (`npm run test:unit`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +```sh +npm run test:e2e +``` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..01eb8d60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# AI Agent Guidelines + +This project uses AI-assisted development with structured guidance in the `ai/` directory. + +## Directory Structure + +Agents should examine the `ai/*` directory listings to understand the available commands, rules, and workflows. + +## Index Files + +Each folder in the `ai/` directory contains an `index.md` file that describes the purpose and contents of that folder. Agents can read these index files to learn the function of files in each folder without needing to read every file. + +**Important:** The `ai/**/index.md` files are auto-generated from frontmatter. Do not create or edit these files manually—they will be overwritten by the pre-commit hook. + +## Progressive Discovery + +Agents should only consume the root index until they need subfolder contents. For example: +- If the project is Python, there is no need to read JavaScript-specific folders +- If working on backend logic, frontend UI folders can be skipped +- Only drill into subfolders when the task requires that specific domain knowledge + +This approach minimizes context consumption and keeps agent responses focused. + +## Vision Document Requirement + +**Before creating or running any task, agents must first read the vision document (`vision.md`) in the project root.** + +The vision document serves as the source of truth for: +- Project goals and objectives +- Key constraints and non-negotiables +- Architectural decisions and rationale +- User experience principles +- Success criteria + +## Conflict Resolution + +If any conflicts are detected between a requested task and the vision document, agents must: + +1. Stop and identify the specific conflict +2. Explain how the task conflicts with the stated vision +3. Ask the user to clarify how to resolve the conflict before proceeding + +Never proceed with a task that contradicts the vision without explicit user approval. + +## Custom Skills and Configuration + +Project-specific customization lives in `aidd-custom/`. Before starting work, +read `aidd-custom/index.md` to discover available project-specific skills, +and read `aidd-custom/config.yml` to load configuration into context. + +## Task Index + +fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (`npm run test:unit`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +```sh +npm run test:e2e +``` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. From 5b321ec3e1ce6d4cdf9e11f063f794ef85ecbde0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:47:46 +0000 Subject: [PATCH 05/12] chore: regenerate index.md files after frontmatter rendering fix The fix(index-generator) commit removed the 'No description available' placeholder for files without frontmatter. This commit reflects the regenerated index.md files with that placeholder removed. --- ai/commands/index.md | 22 ---------------------- ai/rules/index.md | 4 ---- 2 files changed, 26 deletions(-) diff --git a/ai/commands/index.md b/ai/commands/index.md index 7a2431d5..23e07f21 100644 --- a/ai/commands/index.md +++ b/ai/commands/index.md @@ -8,65 +8,43 @@ This index provides an overview of the contents in this directory. **File:** `aidd-fix.md` -*No description available* - ### Commit **File:** `commit.md` -*No description available* - ### discover **File:** `discover.md` -*No description available* - ### execute **File:** `execute.md` -*No description available* - ### help **File:** `help.md` -*No description available* - ### log **File:** `log.md` -*No description available* - ### plan **File:** `plan.md` -*No description available* - ### 🔬 Code Review **File:** `review.md` -*No description available* - ### run-test **File:** `run-test.md` -*No description available* - ### task **File:** `task.md` -*No description available* - ### user-test **File:** `user-test.md` -*No description available* - diff --git a/ai/rules/index.md b/ai/rules/index.md index 6001f82e..dd13d01e 100644 --- a/ai/rules/index.md +++ b/ai/rules/index.md @@ -60,8 +60,6 @@ When writing functional requirements for a user story, use this guide for functi **File:** `review-example.md` -*No description available* - ### 🔬 Code Review **File:** `review.mdc` @@ -84,8 +82,6 @@ when the user asks you to complete a task, use this guide for systematic task/ep **File:** `tdd.mdc` -*No description available* - ### UI/UX Engineer **File:** `ui.mdc` From dfa7616939c5ec6f6fff03ea4d4ef531dc451ca5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:48:15 +0000 Subject: [PATCH 06/12] chore: remove remaining description placeholders from index.md files --- ai/rules/javascript/index.md | 4 ---- ai/skills/aidd-ecs/index.md | 2 -- ai/skills/aidd-layout/references/index.md | 2 -- ai/skills/aidd-namespace/index.md | 2 -- 4 files changed, 10 deletions(-) diff --git a/ai/rules/javascript/index.md b/ai/rules/javascript/index.md index e4cc0a42..1972fe9b 100644 --- a/ai/rules/javascript/index.md +++ b/ai/rules/javascript/index.md @@ -8,8 +8,6 @@ This index provides an overview of the contents in this directory. **File:** `error-causes.mdc` -*No description available* - ### JavaScript IO Guide **File:** `javascript-io-network-effects.mdc` @@ -20,5 +18,3 @@ When you need to make network requests or invoke side-effects, use this guide fo **File:** `javascript.mdc` -*No description available* - diff --git a/ai/skills/aidd-ecs/index.md b/ai/skills/aidd-ecs/index.md index b47a2955..27d7ebbd 100644 --- a/ai/skills/aidd-ecs/index.md +++ b/ai/skills/aidd-ecs/index.md @@ -14,5 +14,3 @@ Enforces @adobe/data/ecs best practices. Use this whenever @adobe/data/ecs is im **File:** `data-modeling.md` -*No description available* - diff --git a/ai/skills/aidd-layout/references/index.md b/ai/skills/aidd-layout/references/index.md index f2ee671e..27bac6cb 100644 --- a/ai/skills/aidd-layout/references/index.md +++ b/ai/skills/aidd-layout/references/index.md @@ -8,5 +8,3 @@ This index provides an overview of the contents in this directory. **File:** `design-tokens.md` -*No description available* - diff --git a/ai/skills/aidd-namespace/index.md b/ai/skills/aidd-namespace/index.md index 325915ab..0f60e144 100644 --- a/ai/skills/aidd-namespace/index.md +++ b/ai/skills/aidd-namespace/index.md @@ -8,8 +8,6 @@ This index provides an overview of the contents in this directory. **File:** `README.md` -*No description available* - ### Type namespace pattern **File:** `SKILL.md` From 782e12a86182d3aaca9e609f405c083a0aeaeb83 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 23:53:24 +0000 Subject: [PATCH 07/12] refactor: extract cli-errors.js and tighten test objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR decomposition constraints: Error types (ValidationError, FileSystemError, CloneError, handleCliErrors) are now defined once in lib/cli-errors.js and imported wherever used — cli-core.js, symlinks.js, and bin/aidd.js — instead of being duplicated or re-exported through cli-core.js. symlinks.js no longer contains a manual cause.code branch in the catch block; the validation guard now runs before the try so it cannot be accidentally caught and re-dispatched. Tests use whole-object assertions instead of one property per assert: - createSymlink error test collapses two tests into one { name, code } check - executeClone error shape test collapses two tests into { isError, causeCode } - syncRootAgentFiles / ensureClaudeMd tests combine action + content checks - agentsMdContent e2e tests merged into one { hasCommand, hasExplanation } --- bin/aidd.js | 3 +- lib/agents-md.test.js | 93 +++++++++++++------------------ lib/cli-core.d.ts | 11 ---- lib/cli-core.js | 22 +------- lib/cli-core.test.js | 26 ++++----- lib/cli-errors.d.ts | 16 ++++++ lib/cli-errors.js | 20 +++++++ lib/symlinks.js | 41 ++++---------- lib/symlinks.test.js | 125 +++++++++++++----------------------------- 9 files changed, 137 insertions(+), 220 deletions(-) create mode 100644 lib/cli-errors.d.ts create mode 100644 lib/cli-errors.js diff --git a/bin/aidd.js b/bin/aidd.js index ecd790b4..593ec9d1 100755 --- a/bin/aidd.js +++ b/bin/aidd.js @@ -8,7 +8,8 @@ import chalk from "chalk"; import { Command } from "commander"; import { syncRootAgentFiles } from "../lib/agents-md.js"; -import { executeClone, handleCliErrors } from "../lib/cli-core.js"; +import { executeClone } from "../lib/cli-core.js"; +import { handleCliErrors } from "../lib/cli-errors.js"; import { generateAllIndexes } from "../lib/index-generator.js"; const __filename = fileURLToPath(import.meta.url); diff --git a/lib/agents-md.test.js b/lib/agents-md.test.js index 5d2d9b9b..a9fed3fe 100644 --- a/lib/agents-md.test.js +++ b/lib/agents-md.test.js @@ -473,21 +473,16 @@ Check aidd-custom/ for project-specific skills and configuration. }); describe("agentsMdContent e2e instruction", () => { - test("instructs agents to run npm run test:e2e before committing", () => { + test("instructs agents to run test:e2e and explains pre-commit hook exclusion", () => { assert({ given: "agentsMdContent", - should: "include the npm run test:e2e command", - actual: agentsMdContent.includes("npm run test:e2e"), - expected: true, - }); - }); - - test("explains that E2E tests are excluded from the pre-commit hook", () => { - assert({ - given: "agentsMdContent", - should: "explain why E2E tests do not run in the pre-commit hook", - actual: agentsMdContent.includes("pre-commit hook"), - expected: true, + should: + "include the test:e2e command and explain the pre-commit hook exclusion", + actual: { + hasCommand: agentsMdContent.includes("npm run test:e2e"), + hasExplanation: agentsMdContent.includes("pre-commit hook"), + }, + expected: { hasCommand: true, hasExplanation: true }, }); }); }); @@ -510,16 +505,18 @@ describe("syncRootAgentFiles", () => { assert({ given: "neither AGENTS.md nor CLAUDE.md exists", - should: "create both files", - actual: results.map(({ action }) => action).every((a) => a === "created"), - expected: true, - }); - - assert({ - given: "files just created", - should: "write agentsMdContent to both", - actual: await fs.readFile(path.join(tempDir, "AGENTS.md"), "utf-8"), - expected: agentsMdContent, + should: "create both files with the current template", + actual: { + actions: results.map(({ action }) => action), + agentsContent: await fs.readFile( + path.join(tempDir, "AGENTS.md"), + "utf-8", + ), + }, + expected: { + actions: ["created", "created"], + agentsContent: agentsMdContent, + }, }); }); @@ -562,16 +559,12 @@ describe("syncRootAgentFiles", () => { assert({ given: "AGENTS.md has stale content", - should: "report updated action", - actual: agentsResult?.action, - expected: "updated", - }); - - assert({ - given: "AGENTS.md updated", - should: "now contain the current template", - actual: await fs.readFile(path.join(tempDir, "AGENTS.md"), "utf-8"), - expected: agentsMdContent, + should: "report updated action and overwrite with the current template", + actual: { + action: agentsResult?.action, + content: await fs.readFile(path.join(tempDir, "AGENTS.md"), "utf-8"), + }, + expected: { action: "updated", content: agentsMdContent }, }); }); }); @@ -593,16 +586,12 @@ describe("ensureClaudeMd", () => { assert({ given: "no CLAUDE.md exists", - should: "report created action", - actual: result.action, - expected: "created", - }); - - assert({ - given: "CLAUDE.md just created", - should: "contain the agentsMdContent template", - actual: await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"), - expected: agentsMdContent, + should: "report created and write the agentsMdContent template", + actual: { + action: result.action, + content: await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"), + }, + expected: { action: "created", content: agentsMdContent }, }); }); @@ -614,20 +603,16 @@ describe("ensureClaudeMd", () => { ); const result = await ensureClaudeMd(tempDir); + const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); assert({ given: "CLAUDE.md without AGENTS.md reference or directives", - should: "report appended action", - actual: result.action, - expected: "appended", - }); - - const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); - assert({ - given: "CLAUDE.md after appending", - should: "contain a reference to AGENTS.md", - actual: content.includes("AGENTS.md"), - expected: true, + should: "report appended and add an AGENTS.md reference", + actual: { + action: result.action, + hasAgentsRef: content.includes("AGENTS.md"), + }, + expected: { action: "appended", hasAgentsRef: true }, }); }); }); diff --git a/lib/cli-core.d.ts b/lib/cli-core.d.ts index 85ad4100..339cf62b 100644 --- a/lib/cli-core.d.ts +++ b/lib/cli-core.d.ts @@ -90,14 +90,3 @@ export function executeClone(options?: { cursor?: boolean; claude?: boolean; }): Promise; - -/** - * Error handler factory for CLI structured errors. - * Accepts a map of handlers keyed by error name and returns a function - * that routes a thrown error to the matching handler. - */ -export const handleCliErrors: (handlers: { - CloneError: (error: Error) => unknown; - FileSystemError: (error: Error) => unknown; - ValidationError: (error: Error) => unknown; -}) => (error: Error) => unknown; diff --git a/lib/cli-core.js b/lib/cli-core.js index 8c58989c..40304491 100644 --- a/lib/cli-core.js +++ b/lib/cli-core.js @@ -2,34 +2,17 @@ import path from "path"; import process from "process"; import { fileURLToPath } from "url"; import chalk from "chalk"; -import { createError, errorCauses } from "error-causes"; +import { createError } from "error-causes"; import fs from "fs-extra"; import { ensureAgentsMd, ensureClaudeMd } from "./agents-md.js"; +import { CloneError, FileSystemError, ValidationError } from "./cli-errors.js"; import { generateIndexRecursive } from "./index-generator.js"; import { createSymlink } from "./symlinks.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// Error causes definition using error-causes library -const [cliErrors, handleCliErrors] = errorCauses({ - CloneError: { - code: "CLONE_ERROR", - message: "AI folder cloning failed", - }, - FileSystemError: { - code: "FILESYSTEM_ERROR", - message: "File system operation failed", - }, - ValidationError: { - code: "VALIDATION_ERROR", - message: "Input validation failed", - }, -}); - -const { ValidationError, FileSystemError, CloneError } = cliErrors; - // Pure path resolution functions const resolvePaths = ({ targetDirectory = ".", @@ -305,7 +288,6 @@ export { createAiddCustomConfig, createLogger, executeClone, - handleCliErrors, resolvePaths, validateSource, validateTarget, diff --git a/lib/cli-core.test.js b/lib/cli-core.test.js index 09471988..31e0f2cb 100644 --- a/lib/cli-core.test.js +++ b/lib/cli-core.test.js @@ -219,27 +219,21 @@ describe("executeClone error result shape", () => { await fs.remove(tempDir); }); - test("returns result.error as an Error instance when clone fails", async () => { + test("returns an Error instance with ValidationError cause when clone fails", async () => { /** @type {any} */ const result = await executeClone({ targetDirectory: tempDir }); assert({ given: "a clone that fails validation", - should: "return result.error as an instanceof Error", - actual: result.success === false && result.error instanceof Error, - expected: true, - }); - }); - - test("result.error has a cause.code identifying the error type", async () => { - /** @type {any} */ - const result = await executeClone({ targetDirectory: tempDir }); - - assert({ - given: "a clone that fails with a validation error", - should: "have result.error.cause.code equal to VALIDATION_ERROR", - actual: result.error?.cause?.code, - expected: "VALIDATION_ERROR", + should: "return an Error instance with a ValidationError cause code", + actual: { + isError: result.error instanceof Error, + causeCode: result.error?.cause?.code, + }, + expected: { + isError: true, + causeCode: "VALIDATION_ERROR", + }, }); }); }); diff --git a/lib/cli-errors.d.ts b/lib/cli-errors.d.ts new file mode 100644 index 00000000..135f49d8 --- /dev/null +++ b/lib/cli-errors.d.ts @@ -0,0 +1,16 @@ +/** Shared CLI error type definitions and handler factory */ + +export const CloneError: { code: string; message: string }; +export const FileSystemError: { code: string; message: string }; +export const ValidationError: { code: string; message: string }; + +/** + * Error handler factory for CLI structured errors. + * Accepts a map of handlers keyed by error name and returns a function + * that routes a thrown error to the matching handler. + */ +export const handleCliErrors: (handlers: { + CloneError: (error: Error) => unknown; + FileSystemError: (error: Error) => unknown; + ValidationError: (error: Error) => unknown; +}) => (error: Error) => unknown; diff --git a/lib/cli-errors.js b/lib/cli-errors.js new file mode 100644 index 00000000..50556bb3 --- /dev/null +++ b/lib/cli-errors.js @@ -0,0 +1,20 @@ +import { errorCauses } from "error-causes"; + +const [cliErrors, handleCliErrors] = errorCauses({ + CloneError: { + code: "CLONE_ERROR", + message: "AI folder cloning failed", + }, + FileSystemError: { + code: "FILESYSTEM_ERROR", + message: "File system operation failed", + }, + ValidationError: { + code: "VALIDATION_ERROR", + message: "Input validation failed", + }, +}); + +const { CloneError, FileSystemError, ValidationError } = cliErrors; + +export { CloneError, FileSystemError, handleCliErrors, ValidationError }; diff --git a/lib/symlinks.js b/lib/symlinks.js index efdf4696..b267271b 100644 --- a/lib/symlinks.js +++ b/lib/symlinks.js @@ -2,17 +2,7 @@ import path from "path"; import { createError } from "error-causes"; import fs from "fs-extra"; -// Reuse the same name/code strings as cli-core.js so handleCliErrors dispatches correctly. -const ValidationError = { - code: "VALIDATION_ERROR", - message: "Input validation failed", - name: "ValidationError", -}; -const FileSystemError = { - code: "FILESYSTEM_ERROR", - message: "File system operation failed", - name: "FileSystemError", -}; +import { FileSystemError, ValidationError } from "./cli-errors.js"; /** * Create a symlink at `/` pointing to the relative path `ai`. @@ -27,28 +17,19 @@ const createSymlink = ({ name, targetBase, force = false }) => async () => { const symlinkPath = path.join(targetBase, name); - const aiRelativePath = "ai"; + const exists = await fs.pathExists(symlinkPath); - try { - const exists = await fs.pathExists(symlinkPath); - - if (exists) { - if (!force) { - throw createError({ - ...ValidationError, - message: `${name} already exists (use --force to overwrite)`, - }); - } - await fs.remove(symlinkPath); - } + if (exists && !force) { + throw createError({ + ...ValidationError, + message: `${name} already exists (use --force to overwrite)`, + }); + } - await fs.symlink(aiRelativePath, symlinkPath); + try { + if (exists) await fs.remove(symlinkPath); + await fs.symlink("ai", symlinkPath); } catch (/** @type {any} */ originalError) { - // Validation errors are already structured — re-throw as-is. - if (originalError.cause?.code === "VALIDATION_ERROR") { - throw originalError; - } - throw createError({ ...FileSystemError, cause: originalError, diff --git a/lib/symlinks.test.js b/lib/symlinks.test.js index 4f2a665a..e93f5abd 100644 --- a/lib/symlinks.test.js +++ b/lib/symlinks.test.js @@ -23,57 +23,29 @@ describe("createSymlink error dispatch", () => { if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); }); - test("existing target without force should set cause.name to ValidationError", async () => { + test("existing target without force throws with ValidationError cause", async () => { await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); + let caughtError; try { await createSymlink({ name: ".cursor", targetBase: tempTestDir, force: false, })(); - assert({ - given: "a symlink target that already exists and force is false", - should: "throw an error", - actual: false, - expected: true, - }); } catch (e) { - const error = /** @type {any} */ (e); - assert({ - given: "a symlink target that already exists and force is false", - should: - "set cause.name to ValidationError for handleCliErrors dispatch", - actual: error.cause.name, - expected: "ValidationError", - }); + caughtError = /** @type {any} */ (e); } - }); - - test("existing target without force should set cause.code to VALIDATION_ERROR", async () => { - await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); - try { - await createSymlink({ - name: ".cursor", - targetBase: tempTestDir, - force: false, - })(); - assert({ - given: "a symlink target that already exists and force is false", - should: "throw an error", - actual: false, - expected: true, - }); - } catch (e) { - const error = /** @type {any} */ (e); - assert({ - given: "a symlink target that already exists and force is false", - should: "set cause.code to VALIDATION_ERROR", - actual: error.cause.code, - expected: "VALIDATION_ERROR", - }); - } + assert({ + given: "a symlink target that already exists and force is false", + should: "throw with ValidationError cause for handleCliErrors dispatch", + actual: { + name: caughtError?.cause?.name, + code: caughtError?.cause?.code, + }, + expected: { name: "ValidationError", code: "VALIDATION_ERROR" }, + }); }); }); @@ -101,39 +73,23 @@ describe("claude symlink functionality", () => { }); }); - test("claude option true should create symlink to ai folder", async () => { + test("claude option true should create symlink pointing to ai/", async () => { await executeClone({ targetDirectory: tempTestDir, claude: true }); - assert({ - given: "claude option is true", - should: "create .claude symlink", - actual: await fs.pathExists(claudePath), - expected: true, - }); - }); - - test("created .claude symlink should point to ai folder", async () => { - await executeClone({ targetDirectory: tempTestDir, claude: true }); - - const symlinkStat = await fs.lstat(claudePath); const symlinkTarget = await fs.readlink(claudePath); assert({ - given: ".claude symlink is created", - should: "be a symbolic link", - actual: symlinkStat.isSymbolicLink(), - expected: true, - }); - - assert({ - given: ".claude symlink is created", - should: "point to ai folder", - actual: symlinkTarget, - expected: "ai", + given: "claude option is true", + should: "create .claude as a symbolic link pointing to ai/", + actual: { + isSymlink: (await fs.lstat(claudePath)).isSymbolicLink(), + target: symlinkTarget, + }, + expected: { isSymlink: true, target: "ai" }, }); }); - test("dry run with claude should not create symlink", async () => { + test("dry run with claude should not create symlink and still succeed", async () => { const result = await executeClone({ targetDirectory: tempTestDir, claude: true, @@ -142,16 +98,12 @@ describe("claude symlink functionality", () => { assert({ given: "dry run mode with claude option", - should: "not actually create symlink", - actual: await fs.pathExists(claudePath), - expected: false, - }); - - assert({ - given: "dry run mode with claude option", - should: "indicate success", - actual: result.success, - expected: true, + should: "not create the symlink and return success", + actual: { + exists: await fs.pathExists(claudePath), + success: result.success, + }, + expected: { exists: false, success: true }, }); }); @@ -192,21 +144,18 @@ describe("cursor and claude symlinks together", () => { claude: true, }); - const cursorStat = await fs.lstat(path.join(tempTestDir, ".cursor")); - const claudeStat = await fs.lstat(path.join(tempTestDir, ".claude")); - - assert({ - given: "both cursor and claude options are true", - should: "create .cursor symlink", - actual: cursorStat.isSymbolicLink(), - expected: true, - }); - assert({ given: "both cursor and claude options are true", - should: "create .claude symlink", - actual: claudeStat.isSymbolicLink(), - expected: true, + should: "create both symlinks pointing to ai/", + actual: { + cursor: ( + await fs.lstat(path.join(tempTestDir, ".cursor")) + ).isSymbolicLink(), + claude: ( + await fs.lstat(path.join(tempTestDir, ".claude")) + ).isSymbolicLink(), + }, + expected: { cursor: true, claude: true }, }); }); }); From 989f429ea19d26a595c422568c45557846a03881 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 00:04:25 +0000 Subject: [PATCH 08/12] refactor(types): define CausedError once; no specific error type assertions Per ErrorConstraints: - lib/cli-errors.d.ts is now the single source of truth for error shapes. Defines CausedError (the common contract for all createError() output) and the error template objects (CloneError, FileSystemError, ValidationError) as plain spread templates, not separate types. handleCliErrors uses Record unknown>. - lib/cli-core.d.ts removes ExecuteCloneError and imports CausedError for the error branch of ExecuteCloneResult. - Tests no longer assert specific error type names or codes (cause.name, cause.code). They verify only that a CausedError was thrown (instanceof Error with a structured cause), which is all that matters for dispatch. --- lib/cli-core.d.ts | 10 +++------- lib/cli-core.test.js | 15 +++++++-------- lib/cli-errors.d.ts | 24 +++++++++++++++--------- lib/symlinks.test.js | 13 ++++++------- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/lib/cli-core.d.ts b/lib/cli-core.d.ts index 339cf62b..160d754e 100644 --- a/lib/cli-core.d.ts +++ b/lib/cli-core.d.ts @@ -2,6 +2,8 @@ * CLI core utilities for the aidd clone operation */ +import type { CausedError } from "./cli-errors.js"; + /** CLI output logger interface */ export interface Logger { cyan: (msg: string) => void; @@ -21,17 +23,11 @@ export interface ResolvedPaths { targetBase: string; } -/** Error detail shape when executeClone fails */ -export interface ExecuteCloneError extends Error { - cause?: unknown; - code?: string; -} - /** Result returned by executeClone — covers success and error shapes */ export type ExecuteCloneResult = | { paths: ResolvedPaths; success: true } | { dryRun: true; success: true } - | { error: Error; success: false }; + | { error: CausedError; success: false }; /** * Create aidd-custom/config.yml with default project settings diff --git a/lib/cli-core.test.js b/lib/cli-core.test.js index 31e0f2cb..333e8a90 100644 --- a/lib/cli-core.test.js +++ b/lib/cli-core.test.js @@ -219,21 +219,20 @@ describe("executeClone error result shape", () => { await fs.remove(tempDir); }); - test("returns an Error instance with ValidationError cause when clone fails", async () => { + test("returns a CausedError when clone fails", async () => { /** @type {any} */ const result = await executeClone({ targetDirectory: tempDir }); assert({ given: "a clone that fails validation", - should: "return an Error instance with a ValidationError cause code", + should: "return success:false with an Error that has a structured cause", actual: { - isError: result.error instanceof Error, - causeCode: result.error?.cause?.code, - }, - expected: { - isError: true, - causeCode: "VALIDATION_ERROR", + success: result.success, + isCausedError: + result.error instanceof Error && + !!(/** @type {any} */ (result.error).cause), }, + expected: { success: false, isCausedError: true }, }); }); }); diff --git a/lib/cli-errors.d.ts b/lib/cli-errors.d.ts index 135f49d8..e0452d7c 100644 --- a/lib/cli-errors.d.ts +++ b/lib/cli-errors.d.ts @@ -1,16 +1,22 @@ -/** Shared CLI error type definitions and handler factory */ +/** Shape of every error created by the error-causes library via createError() */ +export interface CausedError extends Error { + cause: { + code: string; + name: string; + message: string; + [key: string]: unknown; + }; +} +/** Error template objects — spread into createError() to throw structured errors */ export const CloneError: { code: string; message: string }; export const FileSystemError: { code: string; message: string }; export const ValidationError: { code: string; message: string }; /** - * Error handler factory for CLI structured errors. - * Accepts a map of handlers keyed by error name and returns a function - * that routes a thrown error to the matching handler. + * Route a thrown CausedError to the matching handler by cause name. + * The returned function is passed directly to .catch() or called with a result.error. */ -export const handleCliErrors: (handlers: { - CloneError: (error: Error) => unknown; - FileSystemError: (error: Error) => unknown; - ValidationError: (error: Error) => unknown; -}) => (error: Error) => unknown; +export const handleCliErrors: ( + handlers: Record unknown>, +) => (error: Error) => unknown; diff --git a/lib/symlinks.test.js b/lib/symlinks.test.js index e93f5abd..59092395 100644 --- a/lib/symlinks.test.js +++ b/lib/symlinks.test.js @@ -23,7 +23,7 @@ describe("createSymlink error dispatch", () => { if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); }); - test("existing target without force throws with ValidationError cause", async () => { + test("existing target without force throws a CausedError", async () => { await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); let caughtError; @@ -39,12 +39,11 @@ describe("createSymlink error dispatch", () => { assert({ given: "a symlink target that already exists and force is false", - should: "throw with ValidationError cause for handleCliErrors dispatch", - actual: { - name: caughtError?.cause?.name, - code: caughtError?.cause?.code, - }, - expected: { name: "ValidationError", code: "VALIDATION_ERROR" }, + should: "throw an Error with a structured cause", + actual: + caughtError instanceof Error && + !!(/** @type {any} */ (caughtError).cause), + expected: true, }); }); }); From d742a99584ed9bea029b1158093ecd4ba67b9e5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 00:06:01 +0000 Subject: [PATCH 09/12] refactor: rename cli-errors -> error-causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/error-causes.js and lib/error-causes.d.ts are the single module for all error-causes library integration — CausedError type, error template objects, and handleCliErrors. Update imports in cli-core.js, symlinks.js, bin/aidd.js, and cli-core.d.ts. --- bin/aidd.js | 2 +- lib/cli-core.d.ts | 2 +- lib/cli-core.js | 6 +++++- lib/{cli-errors.d.ts => error-causes.d.ts} | 0 lib/{cli-errors.js => error-causes.js} | 0 lib/symlinks.js | 2 +- 6 files changed, 8 insertions(+), 4 deletions(-) rename lib/{cli-errors.d.ts => error-causes.d.ts} (100%) rename lib/{cli-errors.js => error-causes.js} (100%) diff --git a/bin/aidd.js b/bin/aidd.js index 593ec9d1..916f516b 100755 --- a/bin/aidd.js +++ b/bin/aidd.js @@ -9,7 +9,7 @@ import { Command } from "commander"; import { syncRootAgentFiles } from "../lib/agents-md.js"; import { executeClone } from "../lib/cli-core.js"; -import { handleCliErrors } from "../lib/cli-errors.js"; +import { handleCliErrors } from "../lib/error-causes.js"; import { generateAllIndexes } from "../lib/index-generator.js"; const __filename = fileURLToPath(import.meta.url); diff --git a/lib/cli-core.d.ts b/lib/cli-core.d.ts index 160d754e..d4dd9cb9 100644 --- a/lib/cli-core.d.ts +++ b/lib/cli-core.d.ts @@ -2,7 +2,7 @@ * CLI core utilities for the aidd clone operation */ -import type { CausedError } from "./cli-errors.js"; +import type { CausedError } from "./error-causes.js"; /** CLI output logger interface */ export interface Logger { diff --git a/lib/cli-core.js b/lib/cli-core.js index 40304491..72199b7e 100644 --- a/lib/cli-core.js +++ b/lib/cli-core.js @@ -6,7 +6,11 @@ import { createError } from "error-causes"; import fs from "fs-extra"; import { ensureAgentsMd, ensureClaudeMd } from "./agents-md.js"; -import { CloneError, FileSystemError, ValidationError } from "./cli-errors.js"; +import { + CloneError, + FileSystemError, + ValidationError, +} from "./error-causes.js"; import { generateIndexRecursive } from "./index-generator.js"; import { createSymlink } from "./symlinks.js"; diff --git a/lib/cli-errors.d.ts b/lib/error-causes.d.ts similarity index 100% rename from lib/cli-errors.d.ts rename to lib/error-causes.d.ts diff --git a/lib/cli-errors.js b/lib/error-causes.js similarity index 100% rename from lib/cli-errors.js rename to lib/error-causes.js diff --git a/lib/symlinks.js b/lib/symlinks.js index b267271b..94e72155 100644 --- a/lib/symlinks.js +++ b/lib/symlinks.js @@ -2,7 +2,7 @@ import path from "path"; import { createError } from "error-causes"; import fs from "fs-extra"; -import { FileSystemError, ValidationError } from "./cli-errors.js"; +import { FileSystemError, ValidationError } from "./error-causes.js"; /** * Create a symlink at `/` pointing to the relative path `ai`. From ee08e5b30611658a102c1f28c48f0c1d20b95ca6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 00:11:08 +0000 Subject: [PATCH 10/12] refactor(error-causes): single d.ts with CausedError only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit error-causes.d.ts now declares exactly two things: - CausedError: the common shape of all createError() output - handleCliErrors: the dispatch factory error-causes.js exports only handleCliErrors. The error template objects (CloneError, FileSystemError, ValidationError) are defined as local constants in cli-core.js and symlinks.js — they are symbolic literals, not types, so local definition is appropriate and avoids exposing them through the type system. --- lib/cli-core.js | 10 +++++----- lib/error-causes.d.ts | 5 ----- lib/error-causes.js | 11 +++-------- lib/symlinks.js | 3 ++- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/lib/cli-core.js b/lib/cli-core.js index 72199b7e..04fc50d7 100644 --- a/lib/cli-core.js +++ b/lib/cli-core.js @@ -6,12 +6,12 @@ import { createError } from "error-causes"; import fs from "fs-extra"; import { ensureAgentsMd, ensureClaudeMd } from "./agents-md.js"; -import { - CloneError, - FileSystemError, - ValidationError, -} from "./error-causes.js"; import { generateIndexRecursive } from "./index-generator.js"; + +const CloneError = { code: "CLONE_ERROR", name: "CloneError" }; +const FileSystemError = { code: "FILESYSTEM_ERROR", name: "FileSystemError" }; +const ValidationError = { code: "VALIDATION_ERROR", name: "ValidationError" }; + import { createSymlink } from "./symlinks.js"; const __filename = fileURLToPath(import.meta.url); diff --git a/lib/error-causes.d.ts b/lib/error-causes.d.ts index e0452d7c..b16cce33 100644 --- a/lib/error-causes.d.ts +++ b/lib/error-causes.d.ts @@ -8,11 +8,6 @@ export interface CausedError extends Error { }; } -/** Error template objects — spread into createError() to throw structured errors */ -export const CloneError: { code: string; message: string }; -export const FileSystemError: { code: string; message: string }; -export const ValidationError: { code: string; message: string }; - /** * Route a thrown CausedError to the matching handler by cause name. * The returned function is passed directly to .catch() or called with a result.error. diff --git a/lib/error-causes.js b/lib/error-causes.js index 50556bb3..99afb2b7 100644 --- a/lib/error-causes.js +++ b/lib/error-causes.js @@ -1,10 +1,7 @@ import { errorCauses } from "error-causes"; -const [cliErrors, handleCliErrors] = errorCauses({ - CloneError: { - code: "CLONE_ERROR", - message: "AI folder cloning failed", - }, +const [, handleCliErrors] = errorCauses({ + CloneError: { code: "CLONE_ERROR", message: "AI folder cloning failed" }, FileSystemError: { code: "FILESYSTEM_ERROR", message: "File system operation failed", @@ -15,6 +12,4 @@ const [cliErrors, handleCliErrors] = errorCauses({ }, }); -const { CloneError, FileSystemError, ValidationError } = cliErrors; - -export { CloneError, FileSystemError, handleCliErrors, ValidationError }; +export { handleCliErrors }; diff --git a/lib/symlinks.js b/lib/symlinks.js index 94e72155..ef6bc6db 100644 --- a/lib/symlinks.js +++ b/lib/symlinks.js @@ -2,7 +2,8 @@ import path from "path"; import { createError } from "error-causes"; import fs from "fs-extra"; -import { FileSystemError, ValidationError } from "./error-causes.js"; +const FileSystemError = { code: "FILESYSTEM_ERROR", name: "FileSystemError" }; +const ValidationError = { code: "VALIDATION_ERROR", name: "ValidationError" }; /** * Create a symlink at `/` pointing to the relative path `ai`. From ea98fdb1ea420e89434193646c089bcfc3ee8a4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 00:13:04 +0000 Subject: [PATCH 11/12] test: replace instanceof assertions with value assertions Test error messages directly instead of checking instanceof Error. Let the type checker handle type assertions automatically. --- lib/cli-core.test.js | 16 +++++++++------- lib/symlinks.test.js | 8 +++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/cli-core.test.js b/lib/cli-core.test.js index 333e8a90..5d38b54e 100644 --- a/lib/cli-core.test.js +++ b/lib/cli-core.test.js @@ -219,20 +219,22 @@ describe("executeClone error result shape", () => { await fs.remove(tempDir); }); - test("returns a CausedError when clone fails", async () => { + test("returns failure with a descriptive error message when clone fails", async () => { /** @type {any} */ const result = await executeClone({ targetDirectory: tempDir }); assert({ - given: "a clone that fails validation", - should: "return success:false with an Error that has a structured cause", + given: "a clone that fails because ai/ already exists", + should: + "return success:false with a message indicating --force is needed", actual: { success: result.success, - isCausedError: - result.error instanceof Error && - !!(/** @type {any} */ (result.error).cause), + message: result.error?.message, + }, + expected: { + success: false, + message: "ai/ folder already exists (use --force to overwrite)", }, - expected: { success: false, isCausedError: true }, }); }); }); diff --git a/lib/symlinks.test.js b/lib/symlinks.test.js index 59092395..6ff4f69f 100644 --- a/lib/symlinks.test.js +++ b/lib/symlinks.test.js @@ -39,11 +39,9 @@ describe("createSymlink error dispatch", () => { assert({ given: "a symlink target that already exists and force is false", - should: "throw an Error with a structured cause", - actual: - caughtError instanceof Error && - !!(/** @type {any} */ (caughtError).cause), - expected: true, + should: "throw with a message indicating --force is needed", + actual: caughtError?.message, + expected: ".cursor already exists (use --force to overwrite)", }); }); }); From 7c9dfbd00d8f51c1d5bf4a06094308df375ba965 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 6 Mar 2026 00:16:30 +0000 Subject: [PATCH 12/12] test: assert values not booleans; fix given/should phrasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - testingSection !== undefined → actual keywords array - results.every(...) → results.map(action) compared to full array - hasAgentsRef boolean → full appended content string - given/should strings updated to describe functional requirements without referencing literal implementation details --- lib/agents-md.test.js | 20 ++++++++++++-------- lib/cli-core.test.js | 5 ++--- lib/symlinks.test.js | 4 ++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/agents-md.test.js b/lib/agents-md.test.js index a9fed3fe..f3401b2b 100644 --- a/lib/agents-md.test.js +++ b/lib/agents-md.test.js @@ -465,9 +465,9 @@ Check aidd-custom/ for project-specific skills and configuration. assert({ given: "directiveAppendSections", - should: "include a section with test:e2e keyword", - actual: testingSection !== undefined, - expected: true, + should: "have a section whose keywords include test:e2e", + actual: testingSection?.keywords, + expected: ["test:e2e"], }); }); }); @@ -537,8 +537,8 @@ describe("syncRootAgentFiles", () => { assert({ given: "both files already match the current template", should: "report unchanged for both", - actual: results.every(({ action }) => action === "unchanged"), - expected: true, + actual: results.map(({ action }) => action), + expected: ["unchanged", "unchanged"], }); }); @@ -607,12 +607,16 @@ describe("ensureClaudeMd", () => { assert({ given: "CLAUDE.md without AGENTS.md reference or directives", - should: "report appended and add an AGENTS.md reference", + should: "report appended and append a pointer to AGENTS.md", actual: { action: result.action, - hasAgentsRef: content.includes("AGENTS.md"), + content, + }, + expected: { + action: "appended", + content: + "# My CLAUDE.md\n\nCustom content.\n\n> **AI agents**: see [AGENTS.md](./AGENTS.md) for project-specific agent guidelines.\n", }, - expected: { action: "appended", hasAgentsRef: true }, }); }); }); diff --git a/lib/cli-core.test.js b/lib/cli-core.test.js index 5d38b54e..6684358e 100644 --- a/lib/cli-core.test.js +++ b/lib/cli-core.test.js @@ -224,9 +224,8 @@ describe("executeClone error result shape", () => { const result = await executeClone({ targetDirectory: tempDir }); assert({ - given: "a clone that fails because ai/ already exists", - should: - "return success:false with a message indicating --force is needed", + given: "the target already contains an ai/ installation", + should: "report failure with an actionable error message", actual: { success: result.success, message: result.error?.message, diff --git a/lib/symlinks.test.js b/lib/symlinks.test.js index 6ff4f69f..61ac416c 100644 --- a/lib/symlinks.test.js +++ b/lib/symlinks.test.js @@ -38,8 +38,8 @@ describe("createSymlink error dispatch", () => { } assert({ - given: "a symlink target that already exists and force is false", - should: "throw with a message indicating --force is needed", + given: "an existing target path and no overwrite permission", + should: "throw with an actionable error message", actual: caughtError?.message, expected: ".cursor already exists (use --force to overwrite)", });