This is rule-composer, a CLI tool for composing, converting, and optimizing AI coding agent rules across 10+ tools. It lives in a pnpm monorepo. The primary codebase is in scripts/ (TypeScript, ESM, tsup build). Documentation is in apps/docs/content/ (Quartz/Markdown).
Two subcommands (compose, decompose) sharing modules in scripts/shared/.
scripts/index.ts → flag parser + subcommand router (compose [path] [-o out] | decompose [path] [-o out])
scripts/compose/index.ts → orchestration: [input?] → scan → select → reorder → compose → optimize → [-o?] write → variants
scripts/decompose/index.ts → orchestration: [input?] → detect → pick → split → select → numbered → placeholder → format → [-o?] write
scripts/shared/ → types, schemas, formats, scanner, openrouter, cli, tree-prompt
Both subcommands accept an optional [path] argument (file or directory) that skips auto-detection, and --output/-o to skip the interactive output prompt. Paths ending with / are treated as directory targets.
- Orchestration (
compose/index.ts,decompose/index.ts) — wires modules together, handles user interaction. - Pure logic (
composer.ts,splitter.ts,matcher.ts) — no I/O, no prompts, fully testable. - I/O adapters (
formats.ts:readRule,writeAsSingleFile,writeAsDirectory) — thin wrappers aroundfs. No formatting, no placeholder resolution. - Formatting happens at the orchestration layer via
formatMarkdown(), not inside write functions. This keeps unit tests unaffected.
- Compose:
compose()increments all heading levels by one per-section (H1 → H2, H2 → H3, etc.) by default to avoid multiple H1s in the combined output (incrementHeadingsoption, defaulttrue). Scoped rules (alwaysApply: false) get a> [!globs] patterns...callout injected after the first heading (embedGlobsoption, defaulttrue). Sections that are skills, agents, or commands get a> [!type] skill|agent|commandcallout so that decomposing the monolith later restores them to the correct dirs. Users can also reorder selected rules (step 3.5) and add numbered prefixes to H2 headings viaaddSectionNumbers()(step 4.5). Both are optional toggles. - Decompose:
extractSectionMetadata()reads optional inline metadata at the start of each split: a plain blockquote fordescription,> [!globs] pattern, and> [!alwaysApply] true|false. These are stripped from the body and passed tobuildRawContent(). If no blockquote description is present, description falls back to the first prose line.unquoteGlobs()reversesquoteGlobs()so Cursor sees native unquotedglobs:values.stripHeadingNumber()removesN.prefixes from H2 headings in both filename and content.writeAsDirectory()uses a single canonical layout: rules inrulesDir(with optional numbered prefixes andrule.directorysubdirs), skills inlayoutRoot/skills/<name>/SKILL.md, agents inlayoutRoot/agents/<name>.md, commands inlayoutRoot/commands/<name>.md. For skills,readRule()derivesnamefrom the parent directory (e.g.skills/organize-commits/SKILL.md→organize-commits), so the directory structure is preserved on read and write. Option{ numbered: true }prefixes rule filenames with zero-padded indices (01-,02-).
- Modular rules use relative file links:
[Rules](./06-rules-and-skills.mdc). - Composed output uses hash anchors:
[Rules](#6-rules-and-skills). - Compose:
resolveRelativeToHash()transforms./NN-slug.ext→#N-slugfor intra-document links (section N = 1-based position). Controlled byresolveLinksoption (defaulttrue). - Decompose:
resolveHashToRelative()transforms#N-slug→./NN-slug.extusing the output filename map (section N →NN-name.ext).
RuleFile is the core data type — everything reads into it and writes from it. compose() takes RuleFile[] and returns a string. splitByHeadings() returns SplitResult[] which the orchestrator converts to RuleFile[].
- Keep interactive prompts in
cli.tsor orchestration files, not in shared modules. - Call
formatMarkdownat the orchestration layer, not insidewriteAsSingleFile/writeAsDirectory.
[!globs] */.mdc
Every .mdc rule file must have valid YAML frontmatter with these fields:
description— One-line summary of what the rule coversalwaysApply— Explicit boolean.truefor project-wide rules (no globs),falsefor scoped rules (with globs)
globs— File patterns that trigger the rule. Unquoted, never wrapped in"..."or[...]- Single:
globs: scripts/**/*.ts - Multiple:
globs: scripts/shared/formats.ts, scripts/decompose/index.ts
- Single:
- Every rule with
globsmust setalwaysApply: false - Every rule without
globsmust setalwaysApply: true - Glob values are always unquoted — quotes cause literal matching, not pattern matching
- Multiple globs use comma-separated values, not YAML arrays —
[...]syntax does not parse correctly - Glob patterns starting with
*(e.g.,**/*.mdc) are invalid YAML (*is a YAML alias character). Cursor handles them natively, butgray-matterwill crash. The CLI pre-quotes these viaquoteGlobs()before parsing.
When authoring a monolithic AGENTS.md for later decompose, you can add optional metadata at the start of each H2 section so decomposed rules get correct frontmatter (important for subagents and skills that rely on description):
- description — Plain blockquote:
> One-line summary.(one or more lines; stripped and used as frontmatterdescription, max 120 chars). - globs — Callout:
> [!globs] pattern(same as composed output). - alwaysApply — Callout:
> [!alwaysApply] trueor> [!alwaysApply] false.
Decompose strips these lines from the body. Omit them for backward compatibility; description then falls back to the first prose line.
[!globs] scripts/shared/formats.ts
TOOL_REGISTRY and TOOL_VARIABLES in scripts/shared/formats.ts define all 10 supported tools.
- Add the ID to
TOOL_IDSintypes.ts - Add a
ToolConfigentry inTOOL_REGISTRY(directories, singleFiles, extension, hasFrontmatter) - Add a variable map in
TOOL_VARIABLES(TOOL_NAME, RULES_DIR, RULES_EXT, SKILLS_DIR, etc.) - Existing tests auto-cover via
TOOL_IDSiteration — no test changes needed - Optionally add tool-specific tests for unusual variable combinations
hasFrontmatter: true— only Cursor (.mdc). All others are plain markdown.- Empty string in
TOOL_VARIABLES= feature not supported → lines referencing it are removed during resolution. extension: ""— tools like Zed/Aider that use a single file with no extension. Variants use.mdfallback.directories: []— tools that only have single-file rules (no directory scanning).
- Keep variable maps exhaustive — every key present for every tool, even if empty string.
- Use the skill
/add-toolfor the full step-by-step workflow.
- Don't add tool-specific logic in
composer.tsorsplitter.ts— resolution happens via the variable map.
[!globs] scripts/*/.ts
Rules use {{VARIABLE_NAME}} syntax. resolvePlaceholders(content, toolId) handles resolution.
{{VAR}}with non-empty value → replaced with the value{{VAR}}with empty string → entire line removed (not just the placeholder)- Unknown
{{VAR}}→ left as-is (passthrough)
When a rule mentions .cursor/skills/ and the target tool has no skills concept (empty value), the whole line disappears. This avoids broken references like "Use for skills." with no path.
If a line has mixed placeholders (one empty, one non-empty), the line is still removed — any empty var kills the line.
readRule() sets hasPlaceholders: true when \{\{\w+\}\} is found in the body. The CLI uses this to show which rules are dynamic vs static.
TOOL_NAME, RULES_DIR, RULES_EXT, SKILLS_DIR, SKILLS_EXT, AGENTS_DIR, COMMANDS_DIR, GLOBAL_RULES, GLOBAL_SKILLS, GLOBAL_AGENTS, GLOBAL_COMMANDS, RULE_EXAMPLE
Project-level dirs: RULES_DIR, SKILLS_DIR, AGENTS_DIR, COMMANDS_DIR (e.g. Cursor: .cursor/rules/, .cursor/skills/, .cursor/agents/, .cursor/commands/). Global dirs: GLOBAL_*. For tools without agents/commands, AGENTS_DIR and COMMANDS_DIR are empty and lines using them are removed when resolving.
See TOOL_VARIABLES in scripts/shared/formats.ts for the full per-tool map.
[!globs] scripts/shared/formats.ts, scripts/decompose/index.ts
detectSourceTool and replaceWithPlaceholders are the reverse of resolvePlaceholders.
detectSourceTool(content) checks only strong signal keys (RULES_DIR, SKILLS_DIR, GLOBAL_RULES, GLOBAL_SKILLS, RULE_EXAMPLE) — never short/generic values. Scores each tool by total matched value length. Highest score wins.
replaceWithPlaceholders(content, toolId) replaces concrete values with {{VAR}} syntax:
- Collects all non-empty variable values for the tool with length >= 4 (skips
.md) - Sorts by value length descending (longest first)
- Replaces globally, returns the count per variable
Longest-first prevents RULE_EXAMPLE (.cursor/rules/my-convention.mdc) from being partially matched by RULES_DIR (.cursor/rules/).
In decompose/index.ts, step 5 (between section selection and output format):
- Combine all split content, run
detectSourceTool - If a tool is detected, dry-run
replaceWithPlaceholdersfor a preview - Show the user what would change (value → placeholder, count)
- If confirmed, apply replacements to each split individually
- Always replace per-split (not on combined content) to keep split boundaries intact
- Show a preview before applying — the user may not want all replacements
[!globs] scripts/*/.ts
Generated files are formatted with Prettier before writing. Formatting happens at the orchestration layer, not in the write functions.
compose/index.ts— formatsfinalContentand each rule'sbody/rawContentbefore writingdecompose/index.ts— formats eachRuleFile'sbody/rawContentbeforewriteAsDirectorycompose/variants.ts— formats each file beforewriteFile(controlled byformatparam)
Exported from scripts/shared/formats.ts. Uses Prettier's Node API with dynamic import. Resolves config from the nearest .prettierrc via prettier.resolveConfig(filepath). Returns content unchanged if Prettier is unavailable.
writeAsSingleFileandwriteAsDirectoryare dumb I/O — tested directly in unit tests- If formatting happened inside them, every unit test would need Prettier or
format: false - Integration tests go through
writeAsDirectorydirectly (not orchestration), so golden files stay unformatted and tests pass without Prettier involvement
- Pass
format: falseinvariants.test.tsto skip Prettier overhead in tests - Regenerate golden fixtures (
pnpm generate-fixtures) if formatting defaults change
[!globs] scripts/decompose/*/.ts
The LLM never generates rule content. It only provides metadata.
- LLM receives the full document + system prompt
- LLM returns JSON:
[{ name, description, headings[], directory? }] headingsare exact H2 text references (or__preamble__for pre-H2 content)reconstructFromHeadings()copies content verbatim from the source document- Validated with
decomposeResponseSchema(Zod)
- Token efficiency — LLM output is small (names + heading refs), not full content
- Content integrity — source content is never rewritten, summarized, or altered by the LLM
- Simpler validation — heading references are easy to verify against the source
2-attempt retry. On validation failure, the error message is appended to the conversation so the LLM can self-correct. Falls back to splitByHeadings() if both attempts fail.
- Always reconstruct from source via
parseHeadingMap+reconstructFromHeadings - Surface warnings for unmatched headings and unclaimed sections
- Never use LLM-generated content as rule body text
- Never skip the Zod validation step
[!globs] scripts//tests//*.test.ts
161 tests across 10 files. Vitest. ESM imports with .js extension.
Factory functions — makeRule(), makeSource() create test data with sensible defaults. Override only what matters for the test.
Temp directories — All filesystem tests use join(tmpdir(), 'arc-test-<name>') with beforeAll/afterAll cleanup. Unique prefix per describe block to avoid parallel collisions.
No mocking — Real filesystem operations against temp dirs. Higher confidence, ~1s total runtime.
Golden fixtures — Integration tests compare against pre-generated files in scripts/shared/__tests__/fixtures/. Regenerate with pnpm generate-fixtures after changing core logic.
- One
describeper export, oneitper behavior. - Consolidate trivially similar tests into one (parameterized or sequential assertions).
variants.test.ts: passformat: falseas 5th arg to skip Prettier in tests.
- Don't mock
fs— use real temp directories. - Don't test interactive prompts or HTTP calls — those are intentionally untested orchestration.
- Don't forget to regenerate fixtures after changing
splitByHeadings,compose, orwriteAsDirectory.
[!globs] apps/docs/**
- Uses Quartz (Preact-based), requires Node >=22 — treat as a standalone Preact project, not Svelte.
- Content is authored in Obsidian and published via Quartz.
- Use GitHub-flavored markdown links (
[text](path)) instead of wikilinks for cross-compatibility. - Frontmatter fields:
title,authors,created,modified.
[!globs] scripts/sync/** scripts/compose/variants.ts coding-tools/**
Use pnpm sync push|pull|diff (or tsx scripts/index.ts sync) to sync repo rules, skills, agents, and (for Cursor) commands with the active tool’s global config. Categories: rules, skills, agents, commands (Cursor only: .cursor/commands/ ↔ ~/.cursor/commands/). A tree prompt lets you pick the source (repo root or coding-tools/<tool>/); --yes uses repo root. All categories for the chosen source are synced. If the repo has a canonical layout (rules/, skills/, agents/, commands/ at root), the CLI asks whether to use it; else it uses the tool’s schema (e.g. .cursor/rules/ for Cursor). For push/pull you are asked: “Do you want to delete stale items (items at the destination that are not present in the source)?” Default no. --yes skips all confirmations (including delete-stale and layout prompt).
- push — repo → global
- pull — global → repo
- diff — show differences only (no writes)
Options: --repo <path>, --tool <id>, --yes (skip confirmations, including delete-stale), --cursor-db (Cursor only, see below). Default tool is cursor; only tools with at least one of GLOBAL_RULES, GLOBAL_SKILLS, GLOBAL_AGENTS, or GLOBAL_COMMANDS in TOOL_VARIABLES are valid.
Implementation: scripts/sync/index.ts uses Node fs (scripts/sync/sync-dir.ts: recursive copy + optional delete-stale). Rules with --cursor-db use the cursor-db path (no syncDir). The sync-agent-config skill is a pointer to this CLI — do not duplicate sync instructions in the skill.
Cursor’s User Rules (Settings → Rules for AI) are stored in SQLite, not in ~/.cursor/rules/. Key: aicontext.personalContext in ItemTable of state.vscdb (paths: macOS ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb, Linux ~/.config/Cursor/User/globalStorage/state.vscdb, Windows %APPDATA%\Cursor\User\globalStorage\state.vscdb). With --cursor-db, sync push composes repo rules/ into one blob and writes to the DB; pull reads from the DB and writes rules/cursor-user-rules.md; diff compares composed repo content to DB content. Implementation: scripts/sync/cursor-db.ts (better-sqlite3). Close Cursor before writing to the DB. If rules don’t show in Settings: run pnpm sync inspect --cursor-db to list ItemTable keys and confirm our key is present; in many Cursor versions User Rules are synced to the cloud and the Settings UI may not read the local DB, so local writes might not appear.
Generated output under coding-tools/<toolId>/:
- Rules:
coding-tools/<toolId>/rules/— one file per rule, tool-specific extension (e.g..mdc,.md,.instructions.md). - Skills:
coding-tools/<toolId>/skills/<skill-name>/SKILL.md— preserve directory structure; alwaysSKILL.md(no tool-specific extension for skills). - README:
coding-tools/<toolId>/README.md— instructs copyingrules/andskills/into the project’s tool config.
Do not flatten skills to skill-name-SKILL.mdc; keep the skill-name/SKILL.md layout per Cursor’s Agent Skills convention.