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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions cli/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
20 changes: 20 additions & 0 deletions cli/src/application/commands/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <tool>", "Limit status to a specific AI tool")
Expand Down
26 changes: 26 additions & 0 deletions cli/src/application/display/installed-rules-display.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
75 changes: 75 additions & 0 deletions cli/src/application/use-cases/list-installed-rules-use-case.ts
Original file line number Diff line number Diff line change
@@ -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<ListInstalledRulesResult> {
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<readonly InstalledRule[]> {
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;
}
}
34 changes: 34 additions & 0 deletions cli/src/domain/capabilities/rules-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
75 changes: 75 additions & 0 deletions cli/src/domain/models/installed-rule.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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 `<name>.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 }),
};
}
14 changes: 2 additions & 12 deletions cli/src/domain/models/plugin-content-translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -353,10 +347,6 @@ function hasAgents(tool: AiTool<HasPlugins>): tool is AiTool<HasPlugins & HasAge
return "agents" in (tool.capabilities as object);
}

function hasRules(tool: AiTool<HasPlugins>): tool is AiTool<HasPlugins & HasRules> {
return "rules" in (tool.capabilities as object);
}

function hasSkills(tool: AiTool<HasPlugins>): tool is AiTool<HasPlugins & HasSkills> {
return "skills" in (tool.capabilities as object);
}
Expand Down
14 changes: 14 additions & 0 deletions cli/src/domain/tools/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TCapabilities>(
tool: AiTool<TCapabilities>
): tool is AiTool<TCapabilities & HasRules> {
return "rules" in (tool.capabilities as object);
}
3 changes: 3 additions & 0 deletions cli/src/infrastructure/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -234,6 +235,7 @@ interface Deps {
* `warnIfFiguresMoveTheTokenToo`. */
telemetrySink: TelemetrySinkAdapter;
forgetTelemetryUseCase: ForgetTelemetryUseCase;
listInstalledRulesUseCase: ListInstalledRulesUseCase;
}

const _cache = new Map<string, Deps>();
Expand Down Expand Up @@ -851,6 +853,7 @@ export async function createDeps(
reportCostUseCase,
telemetrySink,
forgetTelemetryUseCase,
listInstalledRulesUseCase: new ListInstalledRulesUseCase(fs),
};
_cache.set(projectRoot, deps);
return deps;
Expand Down
Loading