diff --git a/README.md b/README.md index eb3042b..231d1ee 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ Both built-in directories are scanned recursively, allowing you to organize rule Use the `additionalRuleDirectories` plugin option to load rules from extra directories, for example a rules folder you already share with other agents. Configured directories are scanned after the built-in locations, in the order you list them; they are additive and never replace or reorder the built-in ones. +`.claude/rules` is not discovered automatically. Add it to this option to reuse Claude Code rules; rules with `paths` frontmatter are treated as path-scoped conditions. + Set it in `opencode.jsonc`: ```jsonc @@ -154,14 +156,18 @@ Set it in `opencode.jsonc`: "package": "opencode-rules@latest", "options": { // A bare string (".agents/rules") is shorthand for a one-entry list. - "additionalRuleDirectories": [".agents/rules", "~/shared/rules"], + "additionalRuleDirectories": [ + ".agents/rules", + ".claude/rules", + "~/shared/rules", + ], }, }, ], } ``` -Or in `cli.json`, with the same option shape and the same `.agents/rules` example: +Or in `cli.json`, with the same option shape and example: ```jsonc // ~/.config/opencode/cli.json @@ -171,7 +177,11 @@ Or in `cli.json`, with the same option shape and the same `.agents/rules` exampl { "package": "opencode-rules@latest", "options": { - "additionalRuleDirectories": [".agents/rules", "~/shared/rules"], + "additionalRuleDirectories": [ + ".agents/rules", + ".claude/rules", + "~/shared/rules", + ], }, }, ], @@ -233,16 +243,17 @@ match: any --- ``` -All matching list fields accept either a YAML array or a single string, which is treated as a one-element array; a declared field with no valid entries never matches. `hooks` remains array-only. Quote values that begin with `{` so YAML does not parse them as flow mappings. +All matching list fields accept either a YAML array or a single string. A string is one pattern, except for `paths`, which also accepts Claude Code's comma-separated string form; commas inside braces or character classes are preserved. A declared field with no valid entries never matches. `hooks` remains array-only. Quote values that begin with `{` so YAML does not parse them as flow mappings. ### Supported Fields -- `globs` (optional): String or array of glob patterns for file-based matching +- `globs` / `paths` (optional): String or array of glob patterns for file-based matching + - `paths` is accepted as a Claude Code-compatible alias; when both are present, their patterns are combined - Rule applies when any file in context matches a pattern - `fileContains` (optional): String or array of literal substrings to find in observed file content - - Rule applies when one observed file's path matches `globs` (if declared) AND the same observation's text contains any literal (OR across literals) + - Rule applies when one observed file's path matches `globs`/`paths` (if declared) AND the same observation's text contains any literal (OR across literals) - Case-sensitive literal matching; regex metacharacters have no special meaning and literals may span lines - - Usable without `globs` to match content alone; a declared field with no valid literal makes the rule never match (fail-closed, with a warning) + - Usable without `globs`/`paths` to match content alone; a declared field with no valid literal makes the rule never match (fail-closed, with a warning) - Observed only from successful Read, Write, Edit, Apply Patch, and LSP tool events - `keywords` (optional): String or array of keywords for prompt-based matching - Rule applies when the user's prompt contains any keyword @@ -278,9 +289,9 @@ All matching list fields accept either a YAML array or a single string, which is ### Matching Behavior - **No metadata**: Rule applies unconditionally (always included) -- **Only globs**: Rule applies when any observed file's path matches +- **Only globs or paths**: Rule applies when any observed file's path matches - **Only fileContains**: Rule applies when any observed file's text contains a literal -- **globs + fileContains**: One file-observation family — a single observed file must satisfy both (AND) +- **globs/paths + fileContains**: One file-observation family — a single observed file must satisfy both (AND) - **Only keywords**: Rule applies when the user's prompt contains any keyword - **Only tools**: Rule applies when any listed tool is available - **Multiple conditions with `match: any` (default)**: Rule applies when ANY condition matches (OR logic across all fields; the file-observation family counts as one check) @@ -301,13 +312,13 @@ The plugin uses `minimatch` for pattern matching: | `*.json` | JSON files in root directory only | | `lib/{utils,helpers}/**/*.js` | JavaScript files in specific lib subdirectories | -A brace pattern can be the whole `globs` value, e.g. `globs: "{src,test}/**/*.ts"`. The quoted scalar is one pattern; use an array to list unrelated patterns. +A brace pattern can be the whole `globs` or `paths` value, e.g. `globs: "{src,test}/**/*.ts"`. For `globs`, the quoted scalar is one pattern; for `paths`, a quoted scalar may also contain comma-separated patterns. Use an array to list unrelated patterns. ## Included Skill: crafting-rules This repository includes a `crafting-rules/` skill that teaches AI agents how to create well-formatted rules. The skill provides: -- **Rule format reference** - Frontmatter fields (`globs`, `fileContains`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`, `ci`, `match`) and markdown body structure +- **Rule format reference** - Frontmatter fields (`globs`/`paths`, `fileContains`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`, `ci`, `match`) and markdown body structure - **Matching strategy guidance** - When to use globs vs keywords vs runtime filters vs combinations - **Pattern extraction workflow** - How to identify repeated conversation patterns that should become rules - **Keyword safety guidelines** - Denylist of overly broad keywords to avoid, allowlist of safe alternatives, and an audit checklist @@ -645,7 +656,7 @@ The plugin registers a `sidebar.content` slot in the OpenCode v2 TUI, displaying - Collapsible "Project" and "Global" sections grouping rules by scope - Active/inactive status indicators (green bullet for active, muted for inactive) based on persisted state from the current session - Condition summary for conditional rules ("always active" for unconditional ones) -- Expandable detail panel with all metadata fields (globs, fileContains, keywords, tools, model, agent, command, project, branch, os, ci, match) +- Expandable detail panel with all metadata fields (globs/paths, fileContains, keywords, tools, model, agent, command, project, branch, os, ci, match); `paths` patterns display under `globs` because the alias is normalized at parse time - Loading, error, and empty states **Behavior:** diff --git a/docs/rules.md b/docs/rules.md index a39de10..9808fb9 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -2,7 +2,7 @@ This document explains how to use OpenCode Rules to inject custom instructions into the agent's context. Rules are automatically discovered and injected via OpenCode's hook system, enabling context-aware rule filtering based on: -- **Legacy filters**: file paths (`globs`), file content (`fileContains`), user prompts (`keywords`), and available tools (`tools`) +- **Legacy filters**: file paths (`globs`/`paths`), file content (`fileContains`), user prompts (`keywords`), and available tools (`tools`) - **Runtime filters**: model, agent, command, project type, git branch, OS, and CI environment - **Match semantics**: `match: any` (default OR logic) or `match: all` (AND logic) @@ -33,13 +33,13 @@ You can create subdirectories to group related rules: └── git.md # Git commit conventions ``` -Hidden files and directories (starting with `.`) are automatically excluded from discovery. +Hidden entries inside a rules directory (names starting with `.`) are excluded from discovery. An explicitly configured rules directory may itself start with a dot, for example `.claude/rules` when added through `additionalRuleDirectories`. ## Conditional Rules You can define conditional rules in both `.md` and `.mdc` files using YAML frontmatter with various filter fields. -List-valued filters (`globs`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`) accept either a single string or a YAML array of strings. A declared filter with no valid entries never matches. Quote values that begin with `{` so YAML does not parse them as flow mappings. +List-valued filters (`globs`, `paths`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`) accept either a single string or a YAML array of strings. A string is one pattern, except for `paths`, which also accepts a comma-separated string; commas inside braces or character classes are preserved. A declared filter with no valid entries never matches. Quote values that begin with `{` so YAML does not parse them as flow mappings. Set `name` to control the short label used when the rule is delivered. The value is trimmed; when omitted, the filename stem is used. @@ -56,10 +56,12 @@ This is a rule for TypeScript components. ### Legacy Filters -#### File-Based Conditions (globs) +#### File-Based Conditions (globs / paths) The `globs` key accepts a single glob pattern or a list of glob patterns. The rule applies if any observed file's path matches one of the patterns. +`paths` is accepted as a Claude Code-compatible alias for `globs`. When both keys are present, their patterns are combined. + ```markdown --- globs: @@ -85,9 +87,9 @@ fileContains: 'unsafe {' Review guidance for unsafe Rust. ``` -When both `globs` and `fileContains` are declared, they form one file-observation family: a single observed file must satisfy the path pattern AND contain a literal. The family counts as one condition in the `match: any|all` algebra — for example, `globs` + `fileContains` + `keywords` under `match: any` means `(globs AND fileContains) OR keywords`. +When both a path filter (`globs` or `paths`) and `fileContains` are declared, they form one file-observation family: a single observed file must satisfy the path pattern AND contain a literal. The family counts as one condition in the `match: any|all` algebra — for example, `globs`/`paths` + `fileContains` + `keywords` under `match: any` means `(path filter AND fileContains) OR keywords`. -`fileContains` can be used without `globs` to match content alone: +`fileContains` can be used without a path filter to match content alone: ```markdown --- @@ -291,7 +293,7 @@ The plugin uses OpenCode's hook system to track context and inject rules: 2. **Rule Injection**: - Matching rules are evaluated against the accumulated context on every user message - Rules are filtered based on: - - **File paths and content** (`globs`, `fileContains`): Glob and literal matching against normalized file observations in context + - **File paths and content** (`globs`/`paths`, `fileContains`): Glob and literal matching against normalized file observations in context - **User prompts** (`keywords`): Keyword matching against the latest user message - **Available tools** (`tools`): Exact match against tool IDs available in the environment - **Model** (`model`): Exact match against current LLM model ID @@ -306,7 +308,7 @@ The plugin uses OpenCode's hook system to track context and inject rules: Rule delivery is split by the rule's **lifetime classification**: - **Session-durable rules** — unconditional rules and rules gated only by - `globs`, `fileContains`, `keywords`, `command`, `project`, `os`, or `ci` — are + `globs`/`paths`, `fileContains`, `keywords`, `command`, `project`, `os`, or `ci` — are published once per turn as one persisted synthetic message via `session.synthetic` on the session `prompt` hook (`resume: false`, no description, so the admitted rule never triggers a reply and stays hidden diff --git a/skills/crafting-rules/SKILL.md b/skills/crafting-rules/SKILL.md index 808ff6b..eb8164e 100644 --- a/skills/crafting-rules/SKILL.md +++ b/skills/crafting-rules/SKILL.md @@ -14,6 +14,7 @@ Rules are markdown files with optional YAML frontmatter, injected into the syste | Field | Type | Category | Purpose | | -------------- | ---------------------- | ---------- | --------------------------------------------------------------- | | `globs` | `string` \| `string[]` | Legacy | Apply when any observed file's path matches a pattern | +| `paths` | `string` \| `string[]` | Legacy | Alias for `globs`; patterns combine when both are present | | `fileContains` | `string` \| `string[]` | Legacy | Apply when an observed file's text contains a literal substring | | `keywords` | `string` \| `string[]` | Legacy | Apply when the user's latest prompt matches a keyword | | `tools` | `string` \| `string[]` | Legacy | Apply when any listed tool ID is available | @@ -27,11 +28,11 @@ Rules are markdown files with optional YAML frontmatter, injected into the syste | `match` | `'any'` \| `'all'` | Combinator | `any` (default): OR logic. `all`: AND logic. | - All fields are optional; no frontmatter means the rule always applies. -- List-valued fields accept either a single string or an array of strings; a string is one pattern. Glob and branch patterns support minimatch brace expansion (e.g. `{src,test}/**/*.ts`); quote brace patterns so YAML does not parse them as flow mappings. A declared field with no valid entries never matches. +- List-valued fields accept either a single string or an array of strings; a string is one pattern, except `paths`, which also accepts a comma-separated string (commas inside braces or character classes are preserved). Glob and branch patterns support minimatch brace expansion (e.g. `{src,test}/**/*.ts`); quote brace patterns so YAML does not parse them as flow mappings. A declared field with no valid entries never matches. - With `match: any` (default), the rule applies if ANY declared condition matches. - With `match: all`, the rule applies only if ALL declared conditions match. -- `globs` and `fileContains` form one file-observation family: when both are declared, the same observed file must satisfy both. The family counts as one condition in the algebra. -- File observations come only from successful live Read, Write, Edit, Apply Patch, and path-associated LSP tool events. Grep, Glob, shell, custom/MCP tools, message prose, and historical tool events contribute nothing to `globs`/`fileContains`. +- `globs` (or `paths`) and `fileContains` form one file-observation family: when both are declared, the same observed file must satisfy both. The family counts as one condition in the algebra. +- File observations come only from successful live Read, Write, Edit, Apply Patch, and path-associated LSP tool events. Grep, Glob, shell, custom/MCP tools, message prose, and historical tool events contribute nothing to `globs`/`paths`/`fileContains`. - A durable rule first matched by a live file observation takes effect at that earliest dispatch — not on the next user message. - When a runtime value is unavailable (e.g., no git repo), that dimension is a non-match. @@ -59,8 +60,8 @@ match: any ## Matching Strategy -- Use `globs` when the rule is about code in specific files/directories. -- Use `fileContains` when the rule targets code patterns inside files (e.g., `unsafe {`, `TODO: fix`); combine with `globs` to scope content to file types. +- Use `globs` (or the Claude Code-compatible `paths` alias) when the rule is about code in specific files/directories. +- Use `fileContains` when the rule targets code patterns inside files (e.g., `unsafe {`, `TODO: fix`); combine with `globs`/`paths` to scope content to file types. - Use `keywords` when the rule is about a topic that may not include files. - Use `tools` when the rule depends on specific MCP tools being available. - Use runtime filters (`model`, `agent`, `command`, `project`, `branch`, `os`, `ci`) to scope rules to specific environments or workflows. diff --git a/src/index.entry.test.ts b/src/index.entry.test.ts index dab8f16..3c041f8 100644 --- a/src/index.entry.test.ts +++ b/src/index.entry.test.ts @@ -9,6 +9,7 @@ import openCodeRulesPlugin from './index.js'; import { createRuntime } from './runtime/create-runtime.js'; import { clearRuleCache } from './rules/rule-discovery.js'; import { + admissionText, createMockPluginInput, getTestDirs, restoreEnv, @@ -55,7 +56,7 @@ async function promptOnce( }); } -describe('plugin entry additionalRuleDirectories option', () => { +describe('plugin entry additional rule directories and Claude Code compatibility', () => { async function setupWithOptions( options?: Record ): Promise> { @@ -98,6 +99,39 @@ describe('plugin entry additionalRuleDirectories option', () => { ).toBe(true); }); + it('treats Claude Code paths frontmatter as a path condition', async () => { + const claudeRulesDir = path.join(projectDir, '.claude', 'rules'); + mkdirSync(claudeRulesDir, { recursive: true }); + writeFileSync( + path.join(claudeRulesDir, 'typescript.md'), + `--- +paths: "src/**/*.ts, lib/**/*.js" +--- + +Use TypeScript conventions.` + ); + + const mockInput = await setupWithOptions({ + additionalRuleDirectories: ['.claude/rules'], + }); + + await promptOnce(mockInput, 'session-paths'); + expect(admissionText(mockInput)).not.toContain( + 'Use TypeScript conventions.' + ); + + await mockInput.hooks.toolAfter[0]!({ + tool: 'read', + sessionID: 'session-paths', + id: 'call_paths', + input: { filePath: 'src/index.ts' }, + status: 'completed', + result: { content: 'Read file src/index.ts, lines 1-1\n1: export {};' }, + }); + + expect(admissionText(mockInput)).toContain('Use TypeScript conventions.'); + }); + it('expands a leading ~ in an entry', async () => { const homeDir = makeRulesDir('home'); process.env.HOME = homeDir; diff --git a/src/rules/rule-metadata.test.ts b/src/rules/rule-metadata.test.ts index 6c80f5f..96e0c0f 100644 --- a/src/rules/rule-metadata.test.ts +++ b/src/rules/rule-metadata.test.ts @@ -99,6 +99,73 @@ rule`; }); }); +describe('paths alias parsing', () => { + it('maps a scalar paths field to globs', () => { + expect(parseRuleMetadata('---\npaths: "**/*.ts"\n---\nrule')).toEqual({ + globs: ['**/*.ts'], + }); + }); + + it('maps an array paths field to globs', () => { + const content = `--- +paths: + - "src/**/*.ts" + - "lib/**/*.js" +--- +rule`; + + expect(parseRuleMetadata(content)).toEqual({ + globs: ['src/**/*.ts', 'lib/**/*.js'], + }); + }); + + it('combines globs and paths patterns', () => { + const content = `--- +globs: "src/**/*.ts" +paths: + - "lib/**/*.js" +--- +rule`; + + expect(parseRuleMetadata(content)).toEqual({ + globs: ['src/**/*.ts', 'lib/**/*.js'], + }); + }); + + it('splits comma-separated paths without splitting brace patterns', () => { + const content = `--- +paths: "src/**/*.ts, lib/**/*.{ts,tsx}, tests/*.ts" +--- +rule`; + + expect(parseRuleMetadata(content)).toEqual({ + globs: ['src/**/*.ts', 'lib/**/*.{ts,tsx}', 'tests/*.ts'], + }); + }); + + it('splits comma-separated entries inside a paths array', () => { + const content = `--- +paths: + - "src/**/*.ts, lib/**/*.js" + - "tests/**/*.ts" +--- +rule`; + + expect(parseRuleMetadata(content)).toEqual({ + globs: ['src/**/*.ts', 'lib/**/*.js', 'tests/**/*.ts'], + }); + }); + + it.each([ + ['null', '---\npaths:\n---\nrule'], + ['an object', '---\npaths: {}\n---\nrule'], + ['an empty array', '---\npaths: []\n---\nrule'], + ['an empty string', '---\npaths: ""\n---\nrule'], + ])('keeps a declared %s paths field fail-closed', (_, content) => { + expect(parseRuleMetadata(content)).toEqual({ globs: [] }); + }); +}); + describe('fileContains parsing', () => { it('parses a scalar string as a one-element array', () => { const content = `--- diff --git a/src/rules/rule-metadata.ts b/src/rules/rule-metadata.ts index 0c4c472..df84f43 100644 --- a/src/rules/rule-metadata.ts +++ b/src/rules/rule-metadata.ts @@ -30,6 +30,7 @@ export interface RuleHook { interface ParsedFrontmatter { name?: unknown; globs?: unknown; + paths?: unknown; fileContains?: unknown; keywords?: unknown; tools?: unknown; @@ -84,6 +85,62 @@ function extractStringArray(value: unknown): string[] | undefined { .filter(v => v.length > 0); } +// `paths` accepts Claude Code's comma-separated string form in addition to +// the scalar/array forms. Split on top-level commas only so brace expansions +// and character classes keep their commas. +function splitPathPatterns(value: string): string[] { + const patterns: string[] = []; + let current = ''; + let braceDepth = 0; + let bracketDepth = 0; + + for (let index = 0; index < value.length; index++) { + const character = value[index]; + + if (character === '\\') { + current += character; + const next = value[index + 1]; + if (next !== undefined) { + current += next; + index++; + } + continue; + } + + if (character === '{') { + braceDepth++; + } else if (character === '}' && braceDepth > 0) { + braceDepth--; + } else if (character === '[') { + bracketDepth++; + } else if (character === ']' && bracketDepth > 0) { + bracketDepth--; + } + + if (character === ',' && braceDepth === 0 && bracketDepth === 0) { + patterns.push(current); + current = ''; + continue; + } + + current += character; + } + + patterns.push(current); + return patterns; +} + +function extractPaths(value: unknown): string[] | undefined { + if (typeof value !== 'string' && !Array.isArray(value)) { + return undefined; + } + return toEntries(value) + .filter((entry): entry is string => typeof entry === 'string') + .flatMap(entry => splitPathPatterns(entry)) + .map(entry => entry.trim()) + .filter(entry => entry.length > 0); +} + export function parseRuleMetadata(content: string): RuleMetadata | null { if (!content.startsWith('---')) { return null; @@ -130,6 +187,16 @@ export function parseRuleMetadata(content: string): RuleMetadata | null { } } + // Claude Code uses `paths` for the same condition; normalize it into + // globs so the rest of the pipeline sees one field. Presence, not + // truthiness, decides fail-closed handling for malformed declarations. + if (parsed.paths !== undefined) { + metadata.globs = [ + ...(metadata.globs ?? []), + ...(extractPaths(parsed.paths) ?? []), + ]; + } + if (parsed.fileContains !== undefined) { const literals = extractFileContains(parsed.fileContains); if (literals.length === 0) {