diff --git a/cli/package.json b/cli/package.json index be97ab781..ebaab02b7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 598, + "bundleBudgetKB": 601, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index d8298e3d3..a93d7b8d8 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -19,6 +19,11 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 598 was set when the journal reader began reading the schema a journal states it was // written under, and the diagnostic gained the reason for refusing one: measured // 594.3 -> 595.8 KB. Same 2.2 KB headroom as the two raises before it. +// 601 was set when `aidd ai rules` took over the rule inventory the explore skill used to +// run as its own script: measured 596.6 -> 599.0 KB, +2.4 KB for a use case, a model, a +// display and the subcommand. It deletes 198 lines from a plugin, which the bundle does +// not carry either way - the trade is a plugin script that had drifted for bytes that are +// measured. Same 2.2 KB headroom as the three raises before it. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/application/commands/ai.ts b/cli/src/application/commands/ai.ts index bc29bb513..434482308 100644 --- a/cli/src/application/commands/ai.ts +++ b/cli/src/application/commands/ai.ts @@ -4,6 +4,10 @@ import type { AiToolId } from "../../domain/models/tool-ids.js"; import { AI_TOOL_IDS, isAiToolId } from "../../domain/models/tool-ids.js"; import type { ToolId } from "../../domain/tools/registry.js"; import { createDeps, createMenuDeps } from "../../infrastructure/deps.js"; +import { + printInstalledRules, + printInstalledRulesJson, +} from "../display/installed-rules-display.js"; import { printUnrestorable } from "../display/restore-display.js"; import { ErrorHandler } from "../error-handler.js"; import { NoManifestError } from "../errors.js"; @@ -112,6 +116,22 @@ export function registerAiCommand(program: Command): void { } }); + ai.command("rules") + .description("List the rules installed in this project, across every AI tool") + .option("--json", "Print the inventory as JSON") + .action(async (cmdOptions: { json?: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const { rules } = await deps.listInstalledRulesUseCase.execute({ projectRoot }); + if (cmdOptions.json) printInstalledRulesJson(output, rules); + else printInstalledRules(output, rules); + } catch (error) { + errorHandler.handle(error); + } + }); + ai.command("status") .description("Show drift for AI tools (optionally filtered by tool and/or plugin)") .option("--tool ", "Limit status to a specific AI tool") diff --git a/cli/src/application/display/installed-rules-display.ts b/cli/src/application/display/installed-rules-display.ts new file mode 100644 index 000000000..742f8b502 --- /dev/null +++ b/cli/src/application/display/installed-rules-display.ts @@ -0,0 +1,26 @@ +import type { InstalledRule } from "../../domain/models/installed-rule.js"; +import type { CLIOutput } from "../output.js"; + +/** The machine-readable form, and the contract the explore skill reads: the same array the + * `list-rules.mjs` this replaced printed, field for field, so a skill consuming it did not + * change when the implementation moved. Two spaces, and a trailing newline, for the same + * reason — a diff of the two outputs is the evidence that the move changed nothing. */ +export function printInstalledRulesJson(output: CLIOutput, rules: readonly InstalledRule[]): void { + output.print(JSON.stringify(rules, null, 2)); +} + +/** One line per rule, the tool first. A project with no rule at all says so rather than + * printing nothing: an empty answer and a command that did not run look identical on a + * terminal, and only one of them is a fact about the project. */ +export function printInstalledRules(output: CLIOutput, rules: readonly InstalledRule[]): void { + if (rules.length === 0) { + output.info("No rules installed for any AI tool."); + return; + } + for (const rule of rules) { + const scope = rule.paths === undefined ? "every file" : rule.paths.join(", "); + output.print(`${rule.tool} ${rule.path}`); + output.print(` ${rule.description === "" ? "(no description)" : rule.description}`); + output.print(` applies to: ${scope}`); + } +} diff --git a/cli/src/application/use-cases/list-installed-rules-use-case.ts b/cli/src/application/use-cases/list-installed-rules-use-case.ts new file mode 100644 index 000000000..e8363bf7e --- /dev/null +++ b/cli/src/application/use-cases/list-installed-rules-use-case.ts @@ -0,0 +1,75 @@ +import { join, relative } from "node:path"; +import type { InstalledRule } from "../../domain/models/installed-rule.js"; +import { toInstalledRule } from "../../domain/models/installed-rule.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../domain/models/tool-ids.js"; +import type { FileReader } from "../../domain/ports/file-reader.js"; +import { hasRules } from "../../domain/tools/contracts.js"; +import { getToolConfig, isAiTool } from "../../domain/tools/registry.js"; + +export interface ListInstalledRulesInput { + readonly projectRoot: string; +} + +export interface ListInstalledRulesResult { + readonly rules: readonly InstalledRule[]; +} + +/** Where this tool's installed rules live, asked of the tool. `undefined` for one that + * registers no rules capability at all, and for one whose installer answers no path — both + * mean there is nothing to scan, and neither is a directory guessed here. */ +function locationOf(toolId: AiToolId): { directory: string; extension: string } | undefined { + const tool = getToolConfig(toolId); + if (!isAiTool(tool) || !hasRules(tool)) return undefined; + return tool.capabilities.rules.installedLocation() ?? undefined; +} + +/** `/`-separated whatever the platform hands back, because the path is data a caller reads + * and compares, not a path it opens. A Windows checkout answering `.claude\rules\a.md` + * would make the same project's rules read differently on two machines. */ +function projectRelative(projectRoot: string, absolutePath: string): string { + return relative(projectRoot, absolutePath).replaceAll("\\", "/"); +} + +/** + * Every rule installed in a project, across every tool that installs any. + * + * Replaces `list-rules.mjs`, which the explore skill shipped and ran directly. That script + * carried its own table of tool directories and extensions, and its own frontmatter parser; + * both already existed here, and the table had drifted — it knew four tools and stated that + * Codex supports no rules, while `plugin-content-translator.ts` installs a plugin's `rules/` + * into every tool whose capability accepts them. Asking each tool where it installs is what + * makes a fifth tool, or a moved directory, impossible to miss. + */ +export class ListInstalledRulesUseCase { + constructor(private readonly files: FileReader) {} + + async execute(input: ListInstalledRulesInput): Promise { + const rules: InstalledRule[] = []; + for (const toolId of AI_TOOL_IDS) { + const location = locationOf(toolId); + if (location === undefined) continue; + rules.push(...(await this.rulesUnder(input.projectRoot, toolId, location))); + } + return { rules }; + } + + /** A directory that is not there yields nothing: `listFilesRecursive` answers an empty + * list for one it cannot read, so a project with a single tool installed is the ordinary + * case here and not a branch. */ + private async rulesUnder( + projectRoot: string, + toolId: AiToolId, + location: { directory: string; extension: string } + ): Promise { + const absolute = join(projectRoot, location.directory); + const found = await this.files.listFilesRecursive(absolute); + const rules: InstalledRule[] = []; + for (const file of found.filter((path) => path.endsWith(location.extension))) { + const content = await this.files.readFile(file); + rules.push( + toInstalledRule(toolId, projectRelative(projectRoot, file), location.extension, content) + ); + } + return rules; + } +} diff --git a/cli/src/domain/capabilities/rules-capability.ts b/cli/src/domain/capabilities/rules-capability.ts index ea192651c..1117fdced 100644 --- a/cli/src/domain/capabilities/rules-capability.ts +++ b/cli/src/domain/capabilities/rules-capability.ts @@ -19,6 +19,40 @@ export class RulesCapability { return `${this.params.directory}rules/${ruleName}${this.params.toolSuffix}`; } + /** A name no rule of a reader's own will ever carry, asked of `buildInstallPath` only so + * its answer can be read back. Long and self-describing on purpose: it appears in no + * output and on no disk, and a short one could collide with a real rule name if a tool + * ever branched on it. */ + private static readonly PROBE_STEM = "aidd-installed-location-probe"; + + /** Where an installed rule of this tool lives, and what it is called at the end — the + * directory and the extension, asked of the installer rather than restated beside it. + * + * `buildOutputPath` answers a different question: where the framework's own source form + * goes. What a reader scanning a project needs is the *installed* shape, and the only + * thing that knows it is `buildInstallPath`, which is a closure written per tool — a + * template for Claude Code, Codex and OpenCode, `toMdc` for Cursor, a delegated handler + * for Copilot. Probing it keeps the answer in the one place that already holds it; a + * caller splitting a path string apart would be a second copy, free to disagree the day + * a tool changes where it installs. + * + * `null` when the tool installs nothing for the name it was asked about, and when it + * answers a path whose stem it rewrote past recognition. Both mean the same thing to a + * caller — nothing here can say where to look — and neither is a guess. */ + installedLocation(): { readonly directory: string; readonly extension: string } | null { + const suffix = this.params.inputSuffix ?? this.params.toolSuffix; + const installed = this.buildInstallPath(`${RulesCapability.PROBE_STEM}${suffix}`); + if (installed === null) return null; + const lastSlash = installed.lastIndexOf("/"); + const basename = installed.slice(lastSlash + 1); + const stemAt = basename.indexOf(RulesCapability.PROBE_STEM); + if (stemAt === -1) return null; + return { + directory: installed.slice(0, lastSlash + 1), + extension: basename.slice(stemAt + RulesCapability.PROBE_STEM.length), + }; + } + buildInstallPath(fileName: string): string | null { return this.params.buildInstallPath(fileName); } diff --git a/cli/src/domain/models/installed-rule.ts b/cli/src/domain/models/installed-rule.ts new file mode 100644 index 000000000..7877d8192 --- /dev/null +++ b/cli/src/domain/models/installed-rule.ts @@ -0,0 +1,75 @@ +import { parseFrontmatter } from "../formats/markdown.js"; +import type { AiToolId } from "../models/tool-ids.js"; + +/** + * One rule as it sits installed in a project, read back rather than generated. + * + * The shape is the one the plugin script this replaced emitted, field for field, so the + * skill that consumes it did not have to change what it reads. What changed is where the + * rows come from: the script carried its own table of four tool directories and their + * extensions, and `RulesCapability.installedLocation()` now answers that per tool, from the + * installer itself. + */ +export interface InstalledRule { + readonly tool: AiToolId; + /** Project-relative, `/`-separated, exactly as the scan found it. */ + readonly path: string; + /** The file's own name with the installed extension removed — never a frontmatter field. + * A rule's identity is where it sits: two rules may state the same `name` and still be + * two rules, and one that states none is still named. */ + readonly name: string; + /** What the rule says it governs, empty where it says nothing. Empty rather than absent: + * every tool's rule may carry one, so a missing description is a rule that stated none, + * not a tool that cannot. */ + readonly description: string; + /** Every glob the rule scopes itself to, absent when it names none — which means it + * applies everywhere, a different statement from an empty list. */ + readonly paths?: readonly string[]; +} + +/** Each tool names the scope field differently: `paths` for Claude Code and Codex, `globs` + * for Cursor, `applyTo` for Copilot. Read all three and merge, rather than branch on the + * tool: a file converted from one tool to another carries whichever its source used, and a + * reader asking one question should not have to know which tool answered. */ +const SCOPE_FIELDS = ["paths", "globs", "applyTo"] as const; + +/** A scope stated as one string may hold several globs: `tool-paths.md` tells a generator + * to comma-join them for Cursor and Copilot. Split, so a rule governing two trees reads as + * two and not as one glob containing a comma. */ +function globsIn(value: unknown): readonly string[] { + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); + if (typeof value !== "string") return []; + return value + .split(",") + .map((glob) => glob.trim()) + .filter((glob) => glob !== ""); +} + +function scopeOf(frontmatter: Record): readonly string[] { + return SCOPE_FIELDS.flatMap((field) => globsIn(frontmatter[field])); +} + +/** The installed extension, whole. Trimming at the last dot would leave `.instructions` + * glued to every Copilot rule's name, since what it installs is `.instructions.md`. */ +function nameOf(path: string, extension: string): string { + const basename = path.split("/").at(-1) ?? path; + return basename.endsWith(extension) ? basename.slice(0, -extension.length) : basename; +} + +export function toInstalledRule( + tool: AiToolId, + path: string, + extension: string, + content: string +): InstalledRule { + const { frontmatter } = parseFrontmatter(content); + const description = frontmatter.description; + const paths = scopeOf(frontmatter); + return { + tool, + path, + name: nameOf(path, extension), + description: typeof description === "string" ? description : "", + ...(paths.length === 0 ? {} : { paths }), + }; +} diff --git a/cli/src/domain/models/plugin-content-translator.ts b/cli/src/domain/models/plugin-content-translator.ts index d4a035917..850afc8e1 100644 --- a/cli/src/domain/models/plugin-content-translator.ts +++ b/cli/src/domain/models/plugin-content-translator.ts @@ -3,14 +3,8 @@ import { flatHooksSharedDirPath } from "../formats/flat-paths.js"; import { parseFrontmatter, serializeFrontmatter } from "../formats/markdown.js"; import { rewritePluginRootToken } from "../formats/plugin-root-token-rewrite.js"; import type { Hasher } from "../ports/hasher.js"; -import type { - AiTool, - HasAgents, - HasCommands, - HasPlugins, - HasRules, - HasSkills, -} from "../tools/contracts.js"; +import type { AiTool, HasAgents, HasCommands, HasPlugins, HasSkills } from "../tools/contracts.js"; +import { hasRules } from "../tools/contracts.js"; import type { ToolConfig } from "../tools/registry.js"; import { isAiTool } from "../tools/registry.js"; import { InstallationFile } from "./file.js"; @@ -353,10 +347,6 @@ function hasAgents(tool: AiTool): tool is AiTool): tool is AiTool { - return "rules" in (tool.capabilities as object); -} - function hasSkills(tool: AiTool): tool is AiTool { return "skills" in (tool.capabilities as object); } diff --git a/cli/src/domain/tools/contracts.ts b/cli/src/domain/tools/contracts.ts index c1c2b9858..f0ffc2ef8 100644 --- a/cli/src/domain/tools/contracts.ts +++ b/cli/src/domain/tools/contracts.ts @@ -102,3 +102,17 @@ export interface IdeToolConfig { readonly directory: string; readonly signalDir: string | null; } + +/** Whether this tool declares a rules capability at all. + * + * Generic over the tool's own capability set so a caller keeps whatever it had already + * narrowed: `plugin-content-translator.ts` asks it of a tool it has narrowed to + * `HasPlugins` and keeps that, while a caller holding an unnarrowed tool gets `HasRules` + * alone. It lived privately in that translator until a second caller needed it; a copy + * beside it would have been free to answer differently about the same tool. + */ +export function hasRules( + tool: AiTool +): tool is AiTool { + return "rules" in (tool.capabilities as object); +} diff --git a/cli/src/infrastructure/deps.ts b/cli/src/infrastructure/deps.ts index e90629aab..93f86690f 100644 --- a/cli/src/infrastructure/deps.ts +++ b/cli/src/infrastructure/deps.ts @@ -40,6 +40,7 @@ import { InstallAiToolUseCase } from "../application/use-cases/install/install-a import { InstallIdeConfigUseCase } from "../application/use-cases/install/install-ide-config-use-case.js"; import { InstallIdeToolUseCase } from "../application/use-cases/install/install-ide-tool-use-case.js"; import { InstallRuntimeConfigUseCase } from "../application/use-cases/install/install-runtime-config-use-case.js"; +import { ListInstalledRulesUseCase } from "../application/use-cases/list-installed-rules-use-case.js"; import { MarketplaceAddUseCase } from "../application/use-cases/marketplace/marketplace-add-use-case.js"; import { MarketplaceCheckUseCase } from "../application/use-cases/marketplace/marketplace-check-use-case.js"; import { MarketplaceListUseCase } from "../application/use-cases/marketplace/marketplace-list-use-case.js"; @@ -234,6 +235,7 @@ interface Deps { * `warnIfFiguresMoveTheTokenToo`. */ telemetrySink: TelemetrySinkAdapter; forgetTelemetryUseCase: ForgetTelemetryUseCase; + listInstalledRulesUseCase: ListInstalledRulesUseCase; } const _cache = new Map(); @@ -851,6 +853,7 @@ export async function createDeps( reportCostUseCase, telemetrySink, forgetTelemetryUseCase, + listInstalledRulesUseCase: new ListInstalledRulesUseCase(fs), }; _cache.set(projectRoot, deps); return deps; diff --git a/cli/tests/application/use-cases/list-installed-rules-use-case.unit.test.ts b/cli/tests/application/use-cases/list-installed-rules-use-case.unit.test.ts new file mode 100644 index 000000000..6ac1212ff --- /dev/null +++ b/cli/tests/application/use-cases/list-installed-rules-use-case.unit.test.ts @@ -0,0 +1,115 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +// Side-effect imports: this use case asks the registry which tools have rules at all, so a +// tool that never registered is a tool it silently cannot see. +import "../../../src/domain/tools/ai/claude.js"; +import "../../../src/domain/tools/ai/codex.js"; +import "../../../src/domain/tools/ai/copilot.js"; +import "../../../src/domain/tools/ai/cursor.js"; +import "../../../src/domain/tools/ai/opencode.js"; +import { ListInstalledRulesUseCase } from "../../../src/application/use-cases/list-installed-rules-use-case.js"; +import type { FileReader } from "../../../src/domain/ports/file-reader.js"; + +const ROOT = "/project"; + +/** A reader answering from a map of paths to content. + * + * Every member of the port is implemented, and the four this use case never calls reject + * rather than return a placeholder: a stub answering `""` for a file nobody asked it about + * would let a use case start reading through the wrong member and still look green. A + * missing directory needs no branch — the real adapter answers an empty list for one it + * cannot read, and so does this. + */ +function readerOf(files: Readonly>): FileReader { + const unused = (member: string) => (): never => { + throw new Error(`this use case does not call ${member}`); + }; + return { + listFilesRecursive: async (dir: string) => + Object.keys(files).filter((path) => path.startsWith(dir.replaceAll("\\", "/"))), + readFile: async (path: string) => files[path.replaceAll("\\", "/")] ?? "", + listDirectory: unused("listDirectory"), + fileExists: unused("fileExists"), + readFileHash: unused("readFileHash"), + isExecutable: unused("isExecutable"), + }; +} + +const at = (relative: string) => join(ROOT, relative).replaceAll("\\", "/"); + +describe("ListInstalledRulesUseCase — every tool's installed rules, in one answer", () => { + it("finds a rule under each tool's own installed directory", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ + [at(".claude/rules/01-standards/1-naming.md")]: "---\ndescription: Names\n---\n", + [at(".cursor/rules/1-naming.mdc")]: "---\n---\n", + [at(".github/instructions/01-naming.instructions.md")]: "---\n---\n", + [at(".codex/rules/1-naming.md")]: "---\n---\n", + [at(".opencode/rules/1-naming.md")]: "---\n---\n", + }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules.map((rule) => rule.tool).sort()).toEqual([ + "claude", + "codex", + "copilot", + "cursor", + "opencode", + ]); + }); + + // The plugin script this replaced knew four directories and stated "Codex CLI: rules not + // supported, skipped". `plugin-content-translator.ts` installs a plugin's `rules/` into + // every tool whose capability accepts them, Codex included, so that answer was wrong and + // silently so: a Codex project asking what rules it had was told none. + it("answers for Codex, which the script it replaces skipped outright", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ [at(".codex/rules/1-naming.md")]: "---\ndescription: Names\n---\n" }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules).toEqual([ + { + tool: "codex", + path: ".codex/rules/1-naming.md", + name: "1-naming", + description: "Names", + }, + ]); + }); + + it("reports a path relative to the project, never the machine it ran on", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ [at(".claude/rules/deep/nested/1-naming.md")]: "---\n---\n" }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules[0]?.path).toBe(".claude/rules/deep/nested/1-naming.md"); + }); + + // A tool's rules directory holds what that tool installs there and nothing else says it + // is a rule. The extension is the only thing separating a Cursor rule from a stray file + // beside it, and it comes from the installer, never from a list written here. + it("passes over a file whose extension is not the one that tool installs", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ + [at(".cursor/rules/1-naming.mdc")]: "---\n---\n", + [at(".cursor/rules/README.md")]: "---\n---\n", + }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules.map((rule) => rule.path)).toEqual([".cursor/rules/1-naming.mdc"]); + }); + + it("answers an empty list, never an error, for a project holding no rule at all", async () => { + const useCase = new ListInstalledRulesUseCase(readerOf({})); + + await expect(useCase.execute({ projectRoot: ROOT })).resolves.toEqual({ rules: [] }); + }); +}); diff --git a/cli/tests/domain/capabilities/rules-capability.unit.test.ts b/cli/tests/domain/capabilities/rules-capability.unit.test.ts index d81a0b1a3..a2c749222 100644 --- a/cli/tests/domain/capabilities/rules-capability.unit.test.ts +++ b/cli/tests/domain/capabilities/rules-capability.unit.test.ts @@ -17,6 +17,44 @@ describe("RulesCapability", () => { }); }); + // Where a rule *lands* is not `buildOutputPath` — that answers where the framework's own + // source form goes. An installed tree holds the converted file, and the one thing that + // knows its shape is `buildInstallPath`, which is a closure per tool: a template for + // three of them, `toMdc` for Cursor, a delegated handler for Copilot. Asking it with a + // sentinel keeps the answer where the knowledge is, instead of a reader parsing a path + // string back apart and becoming a second copy of it. + describe("installedLocation", () => { + it("answers the directory and the extension an installed rule actually carries", () => { + const cap = new RulesCapability({ + ...params, + buildInstallPath: (fileName) => `.claude/rules/${fileName.replace(".claude.md", ".md")}`, + }); + + expect(cap.installedLocation()).toEqual({ directory: ".claude/rules/", extension: ".md" }); + }); + + it("reads an extension of several segments, which is what Copilot installs", () => { + const cap = new RulesCapability({ + ...params, + buildInstallPath: (fileName) => + `.github/instructions/${fileName.replace(".claude.md", ".instructions.md")}`, + }); + + expect(cap.installedLocation()).toEqual({ + directory: ".github/instructions/", + extension: ".instructions.md", + }); + }); + + // A tool free to answer `null` for a name it will not install is free to answer `null` + // here, and a caller scans nothing rather than guessing a directory. + it("answers nothing when the tool installs no rule for the name it is asked about", () => { + const cap = new RulesCapability({ ...params, buildInstallPath: () => null }); + + expect(cap.installedLocation()).toBeNull(); + }); + }); + describe("accepts", () => { it("returns true when path starts with directory", () => { const cap = new RulesCapability(params); diff --git a/cli/tests/domain/models/installed-rule.unit.test.ts b/cli/tests/domain/models/installed-rule.unit.test.ts new file mode 100644 index 000000000..e6bd9286b --- /dev/null +++ b/cli/tests/domain/models/installed-rule.unit.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { toInstalledRule } from "../../../src/domain/models/installed-rule.js"; + +describe("toInstalledRule — one installed file, read as a rule", () => { + it("names the rule from its own file, never from the frontmatter", () => { + const rule = toInstalledRule( + "claude", + ".claude/rules/01-standards/1-naming.md", + ".md", + "---\n---\n" + ); + + expect(rule.name).toBe("1-naming"); + expect(rule.path).toBe(".claude/rules/01-standards/1-naming.md"); + expect(rule.tool).toBe("claude"); + }); + + // Copilot's installed extension is several segments long, so trimming at the last dot + // would leave `.instructions` glued to every Copilot rule's name. + it("trims the whole installed extension, however many segments it carries", () => { + const rule = toInstalledRule( + "copilot", + ".github/instructions/02-naming.instructions.md", + ".instructions.md", + "" + ); + + expect(rule.name).toBe("02-naming"); + }); + + it("reads the description a rule states, and an empty one where it states none", () => { + const described = toInstalledRule( + "cursor", + ".cursor/rules/a.mdc", + ".mdc", + "---\ndescription: Names files\n---\n" + ); + const bare = toInstalledRule("cursor", ".cursor/rules/b.mdc", ".mdc", "# no frontmatter\n"); + + expect(described.description).toBe("Names files"); + expect(bare.description).toBe(""); + }); + + /** Each tool names the scope field differently — `paths` for Claude Code and Codex, + * `globs` for Cursor, `applyTo` for Copilot — and a reader comparing two tools needs one + * name. Merged rather than picked: a file converted between tools can carry more than + * one, and dropping either would lose a scope the rule really states. */ + it("merges every spelling of the scope field into one list", () => { + const rule = toInstalledRule( + "cursor", + ".cursor/rules/a.mdc", + ".mdc", + '---\npaths:\n - "src/**"\nglobs:\n - "tests/**"\napplyTo: "docs/**"\n---\n' + ); + + expect(rule.paths).toEqual(["src/**", "tests/**", "docs/**"]); + }); + + // A rule that names no scope applies everywhere, which is a different statement from + // "applies to an empty list of paths" — so the field is absent, never an empty array. + it("states no scope at all for a rule that names none", () => { + const rule = toInstalledRule("claude", ".claude/rules/a.md", ".md", "---\n---\n"); + + expect(rule.paths).toBeUndefined(); + }); + + /** `globs: "a, b"` is what `tool-paths.md` tells a generator to write for Cursor, and a + * reader that kept it whole would answer one glob where the rule states two. */ + it("splits a comma-joined scope, which is the form Cursor is generated with", () => { + const rule = toInstalledRule( + "cursor", + ".cursor/rules/a.mdc", + ".mdc", + '---\nglobs: "src/**, tests/**"\n---\n' + ); + + expect(rule.paths).toEqual(["src/**", "tests/**"]); + }); +}); diff --git a/cli/tests/domain/tools/registry-conformance.unit.test.ts b/cli/tests/domain/tools/registry-conformance.unit.test.ts index cb78be8dd..e1aedcf1b 100644 --- a/cli/tests/domain/tools/registry-conformance.unit.test.ts +++ b/cli/tests/domain/tools/registry-conformance.unit.test.ts @@ -13,6 +13,7 @@ import { } from "../../../src/domain/models/plugin-format.js"; import { AI_TOOL_IDS } from "../../../src/domain/models/tool-ids.js"; import type { AiTool } from "../../../src/domain/tools/contracts.js"; +import { hasRules } from "../../../src/domain/tools/contracts.js"; import { getAllRegisteredTools, getToolConfig, @@ -266,3 +267,36 @@ describe("no parallel list references an unregistered tool", () => { } }); }); + +/** + * Where each tool installs a rule, pinned as a table rather than described. + * + * The plugin script this replaced carried its own copy of these five rows and was missing + * one: it stated "Codex CLI: rules not supported, skipped" while `.codex/rules/` is exactly + * where a Codex rule lands. A reader on a Codex project asking what rules it had was + * answered "none", silently and wrongly, because the copy had drifted from the installer. + * + * Written out here so the drift cannot come back quietly: a tool whose install path moves, + * or a sixth tool added with rules, fails this and is read by whoever changes it. + */ +describe("every tool says where its own installed rules live", () => { + const EXPECTED: Readonly> = { + claude: { directory: ".claude/rules/", extension: ".md" }, + codex: { directory: ".codex/rules/", extension: ".md" }, + copilot: { directory: ".github/instructions/", extension: ".instructions.md" }, + cursor: { directory: ".cursor/rules/", extension: ".mdc" }, + opencode: { directory: ".opencode/rules/", extension: ".md" }, + }; + + it("answers the directory and extension each one actually installs into", () => { + const answered = Object.fromEntries( + AI_TOOL_IDS.map((id) => { + const tool = getToolConfig(id); + const rules = isAiTool(tool) && hasRules(tool) ? tool.capabilities.rules : undefined; + return [id, rules?.installedLocation() ?? null]; + }) + ); + + expect(answered).toEqual(EXPECTED); + }); +}); diff --git a/cli/tests/e2e/ai-rules.e2e.test.ts b/cli/tests/e2e/ai-rules.e2e.test.ts new file mode 100644 index 000000000..1bf24732f --- /dev/null +++ b/cli/tests/e2e/ai-rules.e2e.test.ts @@ -0,0 +1,59 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { CLI_PATH, execFileAsync } from "./helpers.js"; + +/** + * The command the explore skill runs. Exercised against the built binary because that is + * what the skill invokes: a use case passing in isolation says nothing about whether the + * subcommand is reachable, and the script this replaced was reachable by construction. + */ +describe("aidd ai rules — the inventory a rule scan reads", () => { + let project: string; + + beforeAll(async () => { + project = await mkdtemp(join(tmpdir(), "aidd-ai-rules-")); + await mkdir(join(project, ".claude/rules/01-standards"), { recursive: true }); + await mkdir(join(project, ".codex/rules"), { recursive: true }); + await mkdir(join(project, ".cursor/rules"), { recursive: true }); + await writeFile( + join(project, ".claude/rules/01-standards/1-naming.md"), + '---\ndescription: Names files\npaths:\n - "src/**/*.ts"\n---\n\n# Naming\n' + ); + await writeFile(join(project, ".codex/rules/2-imports.md"), "---\n---\n\n# Imports\n"); + // Beside a rule, and not one: only the extension the tool installs makes it a rule. + await writeFile(join(project, ".cursor/rules/README.md"), "# not a rule\n"); + }); + + afterAll(async () => { + await rm(project, { recursive: true, force: true }); + }); + + it("answers with every rule installed, whatever tool installed it", async () => { + const { stdout } = await execFileAsync("node", [CLI_PATH, "ai", "rules", "--json"], { + cwd: project, + }); + + expect(JSON.parse(stdout)).toEqual([ + { + tool: "claude", + path: ".claude/rules/01-standards/1-naming.md", + name: "1-naming", + description: "Names files", + paths: ["src/**/*.ts"], + }, + { tool: "codex", path: ".codex/rules/2-imports.md", name: "2-imports", description: "" }, + ]); + }); + + it("says a project holds none rather than printing nothing", async () => { + const empty = await mkdtemp(join(tmpdir(), "aidd-ai-rules-empty-")); + try { + const { stdout } = await execFileAsync("node", [CLI_PATH, "ai", "rules"], { cwd: empty }); + expect(stdout).toContain("No rules installed"); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2019e741d..c12aa3feb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,7 +39,7 @@ Declared in `plugins//hooks/hooks.json`. They run Node, so users need `n | Plugin | Event | Runs | Purpose | | ---------------- | ----------------------------------------- | ------------------------- | --------------------------------------------------------------------- | -| `aidd-context` | `SessionStart` | `hooks/update_memory.cjs` | Refresh the project memory block in the AI context files | +| `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | | `aidd-telemetry` | `SessionStart` · `Stop` · `PostToolUse` | `hooks/journal.cjs` | Journal every session so a unit of work can be tied to what it cost | A hook is authored once, with `${CLAUDE_PLUGIN_ROOT}`, and the installer rewrites it to whatever the target tool expands. Which tools run a bundled hook at all, and what each resolves: @@ -54,6 +54,34 @@ A hook is authored once, with `${CLAUDE_PLUGIN_ROOT}`, and the installer rewrite A tool that runs no hook says why, and an install that carries one tells whoever ran it what was skipped. +## ⚖️ What runs on every event, and what runs when someone asks + +Measured on one machine, 12 runs each, median: the bundled hook starts in **27 ms**, the CLI +in **180 ms** — 6.7× — and `PostToolUse` fires on every tool call a session makes. A +thousand tool calls is 153 seconds of added latency, so the difference is not a preference. + +The line is therefore **not** "plugin or CLI". It is what the code is answering to: + +| | Triggered by | Latency | Runs as | +| --- | --- | --- | --- | +| Observing | a tool event, thousands of times a session | must not be felt | plain Node in `hooks/`, no install, no dependency | +| Answering | a person or a skill, once | irrelevant | the `aidd` CLI | + +Two consequences, both already paid for: + +- A capability that answers belongs in the CLI even when a plugin is what asks for it. The + telemetry pivot deleted 25 files and 4,355 lines of skill-owned scripts on that argument: + one implementation cannot drift from a copy of itself, and the copies had drifted. +- A skill that needs the CLI must say so out loud when it is absent, never quietly do + nothing. The wording is pinned identically across every such skill by + `scripts/__tests__/telemetry-cli-required.test.js`, so a fourth skill cannot invent a + fourth phrasing. + +The cost of the pivot is real and is stated rather than argued away: a plugin that once +promised "no npm install, no CLI, no account" now needs `node` to measure and `aidd` to +answer. Writing that a hook can move to the CLI, or that a skill may keep its own script +because it is small, re-opens a question that was settled with numbers. + ## 🧠 Plugin concerns and layers Every capability lives in exactly one plugin, chosen by **concern**. This taxonomy decides placement; it is only implicit in each `plugin.json`, so it is canonical here. diff --git a/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md b/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md index 8684504e2..ba7cbe23b 100644 --- a/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md @@ -9,16 +9,16 @@ The per-tool rules path and write targets. Rule slice only, nothing about skills | Claude Code | `.claude/rules//.md` | yes | | Cursor | `.cursor/rules//.mdc` | yes | | GitHub Copilot | `.github/instructions/-.instructions.md` | yes (flat) | -| OpenCode | - | no | -| Codex CLI | - | no | +| OpenCode | `.opencode/rules//.md` | yes | +| Codex CLI | `.codex/rules//.md` | yes | `` is the file name `#-slug` from `rule-authoring.md` (e.g. `2-python-fstrings`). `` is that slug with its leading category digit dropped (`python-fstrings`). `` is the folder `-`, the zero-padded category index plus the category name from the taxonomy, e.g. `01-standards`. `` is that same two-digit index. Copilot is flat: no category folder. Its file is `-`, e.g. `2-python-fstrings` becomes `02-python-fstrings` (one category prefix, no folder). -When a tool does not support rules, skip it and say what to do instead: -- **OpenCode**: no rules surface. Add the convention to AGENTS.md, or list its path under `instructions:` in opencode.json. -- **Codex CLI**: rules are skipped at install. Keep the convention in AGENTS.md. +Every tool above installs rules. This table used to say OpenCode and Codex do not, and to tell a generator to put the convention in AGENTS.md instead; both were false. `plugin-content-translator.ts` routes a plugin's `rules/` into every tool whose capability accepts them, and all five accept them — `aidd ai rules` prints where each one lands, asked of the installer itself rather than of a list. + +Both use the same frontmatter as Claude Code: `paths` (array of globs), omitted for an all-files rule. ## Scope frontmatter per tool diff --git a/plugins/aidd-context/skills/11-explore/actions/02-drill.md b/plugins/aidd-context/skills/11-explore/actions/02-drill.md index 042b8160b..059ea9e0d 100644 --- a/plugins/aidd-context/skills/11-explore/actions/02-drill.md +++ b/plugins/aidd-context/skills/11-explore/actions/02-drill.md @@ -14,7 +14,7 @@ A full listing of the current level, and, when the user gave a goal, a single be 1. **Detect the tools if entered cold.** When the survey did not run first, detect the project's AI tools from the signals in [ai-mapping.md](../references/ai-mapping.md) before listing anything. Propose the set when it is ambiguous. 2. **Set the scope.** One axis, or all three. For all, take each axis in turn at one level only, never auto-descend every leaf at once. -3. **List the level.** Enumerate the current level in full from the same sources as the survey ([ai-mapping.md](../references/ai-mapping.md) for the Tooling and Context surfaces). The top level of an axis is its surfaces or items, a deeper level is one item's internals, a skill's actions, a memory file's sections, a module's files. For a Tooling surface, render a table: the item, where it lives, and its one-line purpose. For a rule scan, the `scripts/list-rules.mjs` helper inventories rules across every tool surface. +3. **List the level.** Enumerate the current level in full from the same sources as the survey ([ai-mapping.md](../references/ai-mapping.md) for the Tooling and Context surfaces). The top level of an axis is its surfaces or items, a deeper level is one item's internals, a skill's actions, a memory file's sections, a module's files. For a Tooling surface, render a table: the item, where it lives, and its one-line purpose. For a rule scan, run `aidd ai rules --json`: it inventories every rule installed in the project, for every tool, asking each tool where it installs rather than carrying a list of directories that can go stale. No output, or a command that is not found, means this machine cannot answer. **Stop, and say that the `aidd` command is required for a rule scan, and can be installed with `npm install -g @ai-driven-dev/cli`.** Never report an empty inventory instead: a missing command is not a project without rules. 4. **Match the goal.** When the user named a goal, score this level's items and pick the single best match. Mention a close second only when it is genuinely tied. 5. **Point.** Give a chosen item's exact invocation path. Never run it. 6. **Descend, loop, or stop.** Offer to expand one item one level deeper, back up, switch axis, or stop. On expand, repeat from step 3 against that item. Stop at a leaf or when the user is done. Wait for the answer. diff --git a/plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs b/plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs deleted file mode 100755 index 37f269f09..000000000 --- a/plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env node -// NOTE: synced from plugins/aidd-dev/scripts/list-rules.mjs. Keep in sync when the source changes. -/** - * list-rules.mjs - * - * Inventory project rules across every installed AI tool surface. - * - * Tools and locations (see references/ai-mapping.md): - * - Claude Code: .claude/rules/**\/*.md - * - Cursor: .cursor/rules/**\/*.mdc - * - GitHub Copilot: .github/instructions/**\/*.instructions.md - * - OpenCode: .opencode/rules/**\/*.md (no frontmatter; name from filename) - * - Codex CLI: rules not supported, skipped - * - * Frontmatter shapes differ per tool. The script normalises every entry to: - * { tool, path, name, description, paths } - * - * - tool : claude | cursor | copilot | opencode - * - path : path relative to --root (defaults to cwd) - * - name : derived from the filename (without extension) - * - description : frontmatter `description` (Cursor, Copilot) or empty for OpenCode/Claude when absent - * - paths : merged glob list from `paths` (Claude), `globs` (Cursor), `applyTo` (Copilot) - * - * Exit code 0; empty array when no tool dir contains rules. - */ - -import { existsSync } from 'node:fs'; -import { readFile, readdir, stat } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; -import { argv, cwd, exit, stderr, stdout } from 'node:process'; - -const TOOL_TARGETS = [ - { tool: 'claude', dir: '.claude/rules', ext: '.md' }, - { tool: 'cursor', dir: '.cursor/rules', ext: '.mdc' }, - { tool: 'copilot', dir: '.github/instructions', ext: '.instructions.md' }, - { tool: 'opencode', dir: '.opencode/rules', ext: '.md' }, -]; - -function parseArgs(args) { - let root = cwd(); - for (let i = 0; i < args.length; i++) { - if (args[i] === '--root' && args[i + 1]) { - root = resolve(args[i + 1]); - i++; - } - } - return { root }; -} - -async function walk(dir, ext) { - const out = []; - let entries; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return out; - } - for (const entry of entries) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...(await walk(full, ext))); - } else if (entry.isFile() && entry.name.endsWith(ext)) { - out.push(full); - } - } - return out; -} - -function extractFrontmatter(content) { - if (!content.startsWith('---')) return null; - const end = content.indexOf('\n---', 3); - if (end === -1) return null; - return content.slice(4, end).replace(/^\n/, ''); -} - -function stripQuotes(value) { - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return value.slice(1, -1); - } - return value; -} - -function parseInlineList(value) { - if (!value.startsWith('[') || !value.endsWith(']')) return null; - const inner = value.slice(1, -1).trim(); - if (inner === '') return []; - return inner.split(',').map((part) => stripQuotes(part.trim())); -} - -function stripInlineComment(line) { - let inSingle = false; - let inDouble = false; - for (let i = 0; i < line.length; i++) { - const c = line[i]; - if (c === "'" && !inDouble) inSingle = !inSingle; - else if (c === '"' && !inSingle) inDouble = !inDouble; - else if (c === '#' && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) { - return line.slice(0, i).trimEnd(); - } - } - return line; -} - -function parseFrontmatter(raw) { - const fm = {}; - let currentKey = null; - let listAcc = null; - for (const rawLine of raw.split('\n')) { - const line = stripInlineComment(rawLine); - if (!line.trim()) continue; - - const listItem = line.match(/^\s*-\s+(.*)$/); - if (listItem && currentKey && listAcc) { - listAcc.push(stripQuotes(listItem[1].trim())); - continue; - } - - const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/); - if (!kv) continue; - const [, key, rawValue] = kv; - const value = rawValue.trim(); - if (value === '') { - currentKey = key; - listAcc = []; - fm[key] = listAcc; - } else { - const inlineList = parseInlineList(value); - fm[key] = inlineList !== null ? inlineList : stripQuotes(value); - currentKey = null; - listAcc = null; - } - } - return fm; -} - -function nameFromPath(relativePath, ext) { - const base = relativePath.split('/').pop(); - return base.endsWith(ext) ? base.slice(0, -ext.length) : base; -} - -function normaliseGlobs(fm) { - const out = []; - if (Array.isArray(fm.paths)) out.push(...fm.paths); - if (Array.isArray(fm.globs)) out.push(...fm.globs); - if (typeof fm.applyTo === 'string' && fm.applyTo.trim()) out.push(fm.applyTo.trim()); - return out; -} - -async function inventoryTool(root, target) { - const absolute = join(root, target.dir); - if (!existsSync(absolute)) return []; - - const dirStat = await stat(absolute).catch(() => null); - if (!dirStat || !dirStat.isDirectory()) return []; - - const files = await walk(absolute, target.ext); - const entries = []; - - for (const file of files) { - const relPath = relative(root, file); - const content = await readFile(file, 'utf8'); - const rawFm = extractFrontmatter(content); - const fm = rawFm ? parseFrontmatter(rawFm) : {}; - - const entry = { - tool: target.tool, - path: relPath, - name: nameFromPath(relPath, target.ext), - description: typeof fm.description === 'string' ? fm.description : '', - }; - - const paths = normaliseGlobs(fm); - if (paths.length > 0) entry.paths = paths; - entries.push(entry); - } - - return entries; -} - -async function main() { - const { root } = parseArgs(argv.slice(2)); - - const aggregated = []; - for (const target of TOOL_TARGETS) { - const entries = await inventoryTool(root, target); - aggregated.push(...entries); - } - - stdout.write(JSON.stringify(aggregated, null, 2) + '\n'); -} - -main().catch((err) => { - stderr.write(`error: ${err.message}\n`); - exit(1); -}); diff --git a/scripts/__tests__/comments-name-files-that-exist.test.js b/scripts/__tests__/comments-name-files-that-exist.test.js index 23c2f2cfd..e6d87e072 100644 --- a/scripts/__tests__/comments-name-files-that-exist.test.js +++ b/scripts/__tests__/comments-name-files-that-exist.test.js @@ -53,6 +53,10 @@ const NAMED_AS_HISTORY = Object.freeze({ /** Named inside a fixture or a runtime path a test builds, never a file of this repository. */ const NOT_A_REPOSITORY_FILE = Object.freeze({ + // A seam artefact one plugin writes into a reader's own project and another reads back — + // named here as the shape of that seam, never as a file this repository holds. + "docs/ARCHITECTURE.md": ["INSTALL.md"], + "docs/CATALOG.md": ["INSTALL.md"], "cli/tests/application/use-cases/doctor-use-case.unit.test.ts": ["@.claude/rules/test.md"], "cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts": [ "dist/cli.js", @@ -67,13 +71,30 @@ function trackedFiles() { return cp.execSync("git ls-files", { cwd: ROOT, encoding: "utf8" }).trim().split("\n"); } +function findFiles(command) { + return cp.execSync(command, { cwd: ROOT, encoding: "utf8" }).trim().split(/\r?\n/).filter(Boolean); +} + function scannedFiles() { - const found = cp.execSync( - "find cli/src cli/tests plugins scripts -type f " + - "\\( -name '*.ts' -o -name '*.cjs' -o -name '*.js' \\) -not -path '*/node_modules/*'", - { cwd: ROOT, encoding: "utf8" } - ); - return found.trim().split("\n"); + return [ + ...findFiles( + "find cli/src cli/tests plugins scripts -type f " + + "\\( -name '*.ts' -o -name '*.cjs' -o -name '*.js' \\) -not -path '*/node_modules/*'" + ), + // docs/ too, and its markdown alone. A durable doc naming a file makes the same promise + // a comment does, and it was the one place nothing kept it: the architecture doc named + // the context plugin's session hook with a cjs extension for a file that has always been + // js. Markdown anywhere else is deliberately out - a skill's own asset and a fixture + // template name illustrative paths on purpose, and scanning those produced 17 findings of + // which none was a fault. This comment itself is why the names above are spelled out in + // prose rather than quoted: a quoted example would be a finding. + // + // Two calls and not one command joined by `;`: `execSync` runs through `cmd.exe` on + // Windows, where `;` separates nothing and the second `find` was passed to the first as + // an argument. Green on macOS, red on the Windows job, which is exactly what that job is + // there for. + ...findFiles("find docs -type f -name '*.md'"), + ]; } function allowed(file, token) {