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
35 changes: 23 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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",
],
},
},
],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:**
Expand Down
18 changes: 10 additions & 8 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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
---
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 6 additions & 5 deletions skills/crafting-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
36 changes: 35 additions & 1 deletion src/index.entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>
): Promise<ReturnType<typeof createMockPluginInput>> {
Expand Down Expand Up @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions src/rules/rule-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `---
Expand Down
Loading
Loading