From 30e7c0ed261282512a4ae3a44eb8c27bcfd06b1f Mon Sep 17 00:00:00 2001 From: reference-week Date: Sat, 5 Sep 2026 14:31:55 +0200 Subject: [PATCH 1/4] docs: state the line between a hook and the CLI, and guard the docs that name files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The telemetry pivot moved 4,355 lines of skill-owned scripts into the CLI on an argument nobody wrote down. What is left outside is one skill script and the bundled hooks, and the reason the hooks are not next is a measurement, not taste: on one machine, 12 runs each, `hooks/journal.cjs` starts in a median 27 ms and `cli/dist/cli.js` in 180 ms, and `PostToolUse` fires on every tool call a session makes. A thousand tool calls is 153 seconds a person waits through. So the line is not "plugin or CLI". It is what the code answers to: observing runs on a tool event thousands of times a session and must not be felt, so it is plain Node with no install behind it; answering runs once because a person or a skill asked, so latency is nothing and it belongs in `aidd`. The section states both consequences already paid for — a capability that answers moves even when a plugin is what asks for it, and a skill needing an absent CLI must say so out loud — and names the guard that already pins the second. It also states the cost rather than arguing it away: a plugin that promised "no npm install, no CLI, no account" now needs `node` to measure and `aidd` to answer. The doc named `hooks/update_memory.cjs`. That file has always been `.js`, in the tree and in `hooks.json`, so a reader following the architecture doc to the context plugin's session hook found nothing. `comments-name-files-that-exist.test.js` existed to catch exactly that and could not see it: it scanned `cli/src`, `cli/tests`, `plugins` and `scripts` for `.ts`, `.cjs` and `.js`, and no markdown at all. It now scans `docs/` markdown as well — extended rather than paired with a second test, since a durable doc naming a file makes the same promise a comment does. Markdown anywhere else stays out, measured rather than assumed: scanning every `.md` in the tree produced 17 findings and not one was a fault — a skill's own asset and a `cli/tests/fixtures` template name illustrative paths on purpose. Of the three findings in `docs/`, one is this bug and two name `INSTALL.md`, a seam artefact one plugin writes into a reader's project for another to read back, now listed in `NOT_A_REPOSITORY_FILE` for what it is. Red before the fix on the one line it names, green after. 371 repository script tests pass, the same count as before: the guard was extended, not duplicated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579 --- docs/ARCHITECTURE.md | 30 ++++++++++++++++++- .../comments-name-files-that-exist.test.js | 14 ++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) 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/scripts/__tests__/comments-name-files-that-exist.test.js b/scripts/__tests__/comments-name-files-that-exist.test.js index 23c2f2cfd..d97dc1b5b 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", @@ -70,7 +74,15 @@ function trackedFiles() { 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/*'", + "\\( -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. + "find docs -type f -name '*.md'", { cwd: ROOT, encoding: "utf8" } ); return found.trim().split("\n"); From ab75c0c4d0bf5ad3739baa9b28d13b0099b07a7a Mon Sep 17 00:00:00 2001 From: reference-week Date: Sat, 5 Sep 2026 14:48:13 +0200 Subject: [PATCH 2/4] feat(cli): aidd ai rules answers what the last plugin script used to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs` was the only script left inside a skill, 198 lines, and its own header said it was "synced from plugins/aidd-dev/scripts/list-rules.mjs. Keep in sync when the source changes" — a directory that no longer exists. A hand-maintained copy whose source is gone, and whose header is false. It had drifted where it mattered. It carried its own table of four tool directories and their extensions and stated "Codex CLI: rules not supported, skipped". `plugin-content-translator.ts` routes a plugin's `rules/` into every tool whose capability accepts them, and all five do, so a Codex project asking what rules it had was answered "none" — silently, and wrongly. `05-rule-generate`'s own `tool-paths.md` said the same of Codex and OpenCode; both rows are corrected here. `RulesCapability.installedLocation()` is what removes the table rather than moving it. It asks `buildInstallPath` with a sentinel name and reads its answer back, so the directory and the extension come from the installer itself: claude .claude/rules/ .md codex .codex/rules/ .md copilot .github/instructions/ .instructions.md cursor .cursor/rules/ .mdc opencode .opencode/rules/ .md Written where the knowledge already is: three of those five are a path template, one goes through `toMdc`, and Copilot's is a delegated handler. A caller splitting a path string apart would have been a second copy, free to disagree the day a tool moves. `registry-conformance` pins all five as a table, so a sixth tool, or a moved directory, is read by whoever changes it. The frontmatter parser goes too: `domain/formats/markdown.ts` already had `parseFrontmatter`, and the script carried a fourth hand-rolled one. `hasRules` was private to `plugin-content-translator.ts` and is now exported from `contracts.ts` beside the type it guards, generic so that translator keeps the narrowing it had. `--json` prints the array the script printed, field for field, so the skill reading it did not change what it reads. The default output is for a person, and a project with no rule says so rather than printing nothing: an empty answer and a command that never ran look identical on a terminal, and only one is a fact. The skill now runs `aidd ai rules --json` and, finding no command, stops and says the CLI is required — never an empty inventory, which is the rule `docs/ARCHITECTURE.md` states for a capability that answers. Bundle 596.6 -> 599.0 KB, budget 598 -> 601, measured and recorded beside the three raises before it. Mutations run, three killed: `installedLocation` answering the source path instead of the installed one (4 red), the scan dropping its extension filter (1), and a name trimmed at the last dot rather than by the whole extension (1). 3,488 CLI tests, 371 repository script tests, typecheck, biome `ci`, knip, jscpd, layering, 0 broken links in 798 files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579 --- cli/package.json | 2 +- cli/scripts/check-bundle-size.mjs | 5 + cli/src/application/commands/ai.ts | 20 ++ .../display/installed-rules-display.ts | 26 +++ .../list-installed-rules-use-case.ts | 75 +++++++ .../domain/capabilities/rules-capability.ts | 34 +++ cli/src/domain/models/installed-rule.ts | 75 +++++++ .../models/plugin-content-translator.ts | 6 +- cli/src/domain/tools/contracts.ts | 14 ++ cli/src/infrastructure/deps.ts | 3 + ...list-installed-rules-use-case.unit.test.ts | 115 ++++++++++ .../rules-capability.unit.test.ts | 38 ++++ .../domain/models/installed-rule.unit.test.ts | 64 ++++++ .../tools/registry-conformance.unit.test.ts | 34 +++ cli/tests/e2e/ai-rules.e2e.test.ts | 59 ++++++ .../05-rule-generate/references/tool-paths.md | 10 +- .../skills/11-explore/actions/02-drill.md | 2 +- .../skills/11-explore/scripts/list-rules.mjs | 198 ------------------ 18 files changed, 570 insertions(+), 210 deletions(-) create mode 100644 cli/src/application/display/installed-rules-display.ts create mode 100644 cli/src/application/use-cases/list-installed-rules-use-case.ts create mode 100644 cli/src/domain/models/installed-rule.ts create mode 100644 cli/tests/application/use-cases/list-installed-rules-use-case.unit.test.ts create mode 100644 cli/tests/domain/models/installed-rule.unit.test.ts create mode 100644 cli/tests/e2e/ai-rules.e2e.test.ts delete mode 100755 plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs 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..9a4375dc9 100644 --- a/cli/src/domain/models/plugin-content-translator.ts +++ b/cli/src/domain/models/plugin-content-translator.ts @@ -8,9 +8,9 @@ import type { HasAgents, HasCommands, HasPlugins, - HasRules, 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 +353,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..ae07c75f0 100644 --- a/cli/src/infrastructure/deps.ts +++ b/cli/src/infrastructure/deps.ts @@ -124,6 +124,7 @@ import { CodexCliAdapter } from "./adapters/codex-cli-adapter.js"; import { CopilotCliAdapter } from "./adapters/copilot-cli-adapter.js"; import { CopilotCostReaderAdapter } from "./adapters/copilot-cost-reader-adapter.js"; import { CurrentVersionAdapter } from "./adapters/current-version-adapter.js"; +import { ListInstalledRulesUseCase } from "../application/use-cases/list-installed-rules-use-case.js"; import { FileAdapter } from "./adapters/file-adapter.js"; import { GhCliAdapter } from "./adapters/gh-cli-adapter.js"; import { GhTokenAdapter } from "./adapters/gh-token-adapter.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..04deace0b --- /dev/null +++ b/cli/tests/domain/models/installed-rule.unit.test.ts @@ -0,0 +1,64 @@ +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/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); -}); From 277125c016c7e444db71ca21cbb5be2c97cf002c Mon Sep 17 00:00:00 2001 From: reference-week Date: Sat, 5 Sep 2026 14:50:45 +0200 Subject: [PATCH 3/4] style(cli): order the imports biome's assist expects Adding the use case to `deps.ts` and the guard to `plugin-content-translator.ts` left both import blocks out of the order `assist/source/organizeImports` fixes to, which `pnpm lint` fails on and `biome ci` alone does not report. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579 --- .../models/plugin-content-translator.ts | 8 +------ cli/src/infrastructure/deps.ts | 2 +- .../domain/models/installed-rule.unit.test.ts | 21 ++++++++++++++++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cli/src/domain/models/plugin-content-translator.ts b/cli/src/domain/models/plugin-content-translator.ts index 9a4375dc9..850afc8e1 100644 --- a/cli/src/domain/models/plugin-content-translator.ts +++ b/cli/src/domain/models/plugin-content-translator.ts @@ -3,13 +3,7 @@ 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, - 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"; diff --git a/cli/src/infrastructure/deps.ts b/cli/src/infrastructure/deps.ts index ae07c75f0..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"; @@ -124,7 +125,6 @@ import { CodexCliAdapter } from "./adapters/codex-cli-adapter.js"; import { CopilotCliAdapter } from "./adapters/copilot-cli-adapter.js"; import { CopilotCostReaderAdapter } from "./adapters/copilot-cost-reader-adapter.js"; import { CurrentVersionAdapter } from "./adapters/current-version-adapter.js"; -import { ListInstalledRulesUseCase } from "../application/use-cases/list-installed-rules-use-case.js"; import { FileAdapter } from "./adapters/file-adapter.js"; import { GhCliAdapter } from "./adapters/gh-cli-adapter.js"; import { GhTokenAdapter } from "./adapters/gh-token-adapter.js"; diff --git a/cli/tests/domain/models/installed-rule.unit.test.ts b/cli/tests/domain/models/installed-rule.unit.test.ts index 04deace0b..e6bd9286b 100644 --- a/cli/tests/domain/models/installed-rule.unit.test.ts +++ b/cli/tests/domain/models/installed-rule.unit.test.ts @@ -3,7 +3,12 @@ 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"); + 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"); @@ -24,7 +29,12 @@ describe("toInstalledRule — one installed file, read as a rule", () => { }); 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 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"); @@ -57,7 +67,12 @@ describe("toInstalledRule — one installed file, read as a rule", () => { /** `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'); + const rule = toInstalledRule( + "cursor", + ".cursor/rules/a.mdc", + ".mdc", + '---\nglobs: "src/**, tests/**"\n---\n' + ); expect(rule.paths).toEqual(["src/**", "tests/**"]); }); From 344c5cf765d74ffa7b7b5780026475a35e797ea0 Mon Sep 17 00:00:00 2001 From: reference-week Date: Sat, 5 Sep 2026 14:57:22 +0200 Subject: [PATCH 4/4] fix(scripts): scan docs with its own find, not one joined by a semicolon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extending `comments-name-files-that-exist.test.js` to `docs/` markdown appended a second `find` to the same command string, separated by `;`. `execSync` runs through `cmd.exe` on Windows, where `;` separates nothing: the second `find` and its arguments were handed to the first as arguments, and the walk came back with a list that named files the repository does not hold. Green on macOS, red on the Windows job — which is exactly what that job is there for, and the reason the split is now two `execSync` calls with no shell operator between them rather than a quoting fix that would have worked on one platform. Line endings are split on either form while the calls are being touched: the same output arrives with `\r\n` on Windows, and a trailing carriage return would have made every path miss. 371 repository script tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579 --- .../comments-name-files-that-exist.test.js | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/scripts/__tests__/comments-name-files-that-exist.test.js b/scripts/__tests__/comments-name-files-that-exist.test.js index d97dc1b5b..e6d87e072 100644 --- a/scripts/__tests__/comments-name-files-that-exist.test.js +++ b/scripts/__tests__/comments-name-files-that-exist.test.js @@ -71,21 +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/*'; " + - // 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. - "find docs -type f -name '*.md'", - { 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) {