opencode-rules-md 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +650 -0
  3. package/dist/cli.mjs +374 -0
  4. package/dist/src/active-rules-state.d.ts +12 -0
  5. package/dist/src/active-rules-state.d.ts.map +1 -0
  6. package/dist/src/active-rules-state.js +118 -0
  7. package/dist/src/active-rules-state.js.map +1 -0
  8. package/dist/src/cli/config.d.ts +62 -0
  9. package/dist/src/cli/config.d.ts.map +1 -0
  10. package/dist/src/cli/config.js +246 -0
  11. package/dist/src/cli/config.js.map +1 -0
  12. package/dist/src/cli/install.d.ts +21 -0
  13. package/dist/src/cli/install.d.ts.map +1 -0
  14. package/dist/src/cli/install.js +63 -0
  15. package/dist/src/cli/install.js.map +1 -0
  16. package/dist/src/cli/main.d.ts +19 -0
  17. package/dist/src/cli/main.d.ts.map +1 -0
  18. package/dist/src/cli/main.js +135 -0
  19. package/dist/src/cli/main.js.map +1 -0
  20. package/dist/src/cli/real-fs.d.ts +26 -0
  21. package/dist/src/cli/real-fs.d.ts.map +1 -0
  22. package/dist/src/cli/real-fs.js +42 -0
  23. package/dist/src/cli/real-fs.js.map +1 -0
  24. package/dist/src/cli/status.d.ts +19 -0
  25. package/dist/src/cli/status.d.ts.map +1 -0
  26. package/dist/src/cli/status.js +58 -0
  27. package/dist/src/cli/status.js.map +1 -0
  28. package/dist/src/debug.d.ts +3 -0
  29. package/dist/src/debug.d.ts.map +1 -0
  30. package/dist/src/debug.js +8 -0
  31. package/dist/src/debug.js.map +1 -0
  32. package/dist/src/git-branch.d.ts +2 -0
  33. package/dist/src/git-branch.d.ts.map +1 -0
  34. package/dist/src/git-branch.js +30 -0
  35. package/dist/src/git-branch.js.map +1 -0
  36. package/dist/src/index.d.ts +26 -0
  37. package/dist/src/index.d.ts.map +1 -0
  38. package/dist/src/index.js +55 -0
  39. package/dist/src/index.js.map +1 -0
  40. package/dist/src/mcp-tools.d.ts +8 -0
  41. package/dist/src/mcp-tools.d.ts.map +1 -0
  42. package/dist/src/mcp-tools.js +18 -0
  43. package/dist/src/mcp-tools.js.map +1 -0
  44. package/dist/src/message-context.d.ts +55 -0
  45. package/dist/src/message-context.d.ts.map +1 -0
  46. package/dist/src/message-context.js +110 -0
  47. package/dist/src/message-context.js.map +1 -0
  48. package/dist/src/message-paths.d.ts +34 -0
  49. package/dist/src/message-paths.d.ts.map +1 -0
  50. package/dist/src/message-paths.js +112 -0
  51. package/dist/src/message-paths.js.map +1 -0
  52. package/dist/src/project-fingerprint.d.ts +2 -0
  53. package/dist/src/project-fingerprint.d.ts.map +1 -0
  54. package/dist/src/project-fingerprint.js +61 -0
  55. package/dist/src/project-fingerprint.js.map +1 -0
  56. package/dist/src/rule-discovery.d.ts +49 -0
  57. package/dist/src/rule-discovery.d.ts.map +1 -0
  58. package/dist/src/rule-discovery.js +143 -0
  59. package/dist/src/rule-discovery.js.map +1 -0
  60. package/dist/src/rule-filter.d.ts +61 -0
  61. package/dist/src/rule-filter.d.ts.map +1 -0
  62. package/dist/src/rule-filter.js +180 -0
  63. package/dist/src/rule-filter.js.map +1 -0
  64. package/dist/src/rule-metadata.d.ts +29 -0
  65. package/dist/src/rule-metadata.d.ts.map +1 -0
  66. package/dist/src/rule-metadata.js +100 -0
  67. package/dist/src/rule-metadata.js.map +1 -0
  68. package/dist/src/runtime-chat.d.ts +34 -0
  69. package/dist/src/runtime-chat.d.ts.map +1 -0
  70. package/dist/src/runtime-chat.js +39 -0
  71. package/dist/src/runtime-chat.js.map +1 -0
  72. package/dist/src/runtime-context.d.ts +25 -0
  73. package/dist/src/runtime-context.d.ts.map +1 -0
  74. package/dist/src/runtime-context.js +109 -0
  75. package/dist/src/runtime-context.js.map +1 -0
  76. package/dist/src/runtime.d.ts +31 -0
  77. package/dist/src/runtime.d.ts.map +1 -0
  78. package/dist/src/runtime.js +238 -0
  79. package/dist/src/runtime.js.map +1 -0
  80. package/dist/src/session-store.d.ts +34 -0
  81. package/dist/src/session-store.d.ts.map +1 -0
  82. package/dist/src/session-store.js +90 -0
  83. package/dist/src/session-store.js.map +1 -0
  84. package/dist/src/utils.d.ts +15 -0
  85. package/dist/src/utils.d.ts.map +1 -0
  86. package/dist/src/utils.js +19 -0
  87. package/dist/src/utils.js.map +1 -0
  88. package/dist/tui/data/rules.d.ts +63 -0
  89. package/dist/tui/data/rules.d.ts.map +1 -0
  90. package/dist/tui/data/rules.js +173 -0
  91. package/dist/tui/data/rules.js.map +1 -0
  92. package/dist/tui/index.d.ts +8 -0
  93. package/dist/tui/index.d.ts.map +1 -0
  94. package/dist/tui/index.js +13 -0
  95. package/dist/tui/index.js.map +1 -0
  96. package/dist/tui/slots/sidebar-content.d.ts +11 -0
  97. package/dist/tui/slots/sidebar-content.d.ts.map +1 -0
  98. package/dist/tui/slots/sidebar-content.js +150 -0
  99. package/dist/tui/slots/sidebar-content.js.map +1 -0
  100. package/package.json +101 -0
  101. package/src/active-rules-state.ts +166 -0
  102. package/src/cli/config.ts +312 -0
  103. package/src/cli/install.ts +94 -0
  104. package/src/cli/main.ts +167 -0
  105. package/src/cli/real-fs.ts +65 -0
  106. package/src/cli/status.ts +80 -0
  107. package/src/debug.ts +9 -0
  108. package/src/git-branch.ts +37 -0
  109. package/src/index.ts +68 -0
  110. package/src/mcp-tools.ts +24 -0
  111. package/src/message-context.ts +139 -0
  112. package/src/message-paths.ts +153 -0
  113. package/src/project-fingerprint.ts +72 -0
  114. package/src/rule-discovery.ts +205 -0
  115. package/src/rule-filter.ts +269 -0
  116. package/src/rule-metadata.ts +161 -0
  117. package/src/runtime-chat.ts +68 -0
  118. package/src/runtime-context.ts +136 -0
  119. package/src/runtime.ts +369 -0
  120. package/src/session-store.ts +129 -0
  121. package/src/utils.ts +36 -0
  122. package/tui/.atl/.skill-registry.cache.json +3 -0
  123. package/tui/.atl/skill-registry.md +57 -0
  124. package/tui/data/rules.ts +230 -0
  125. package/tui/index.tsx +23 -0
  126. package/tui/slots/sidebar-content.tsx +364 -0
  127. package/tui/types/opencode-plugin-tui.d.ts +78 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Message path extraction utilities
3
+ */
4
+
5
+ /**
6
+ * Message part types from OpenCode plugin API
7
+ */
8
+ interface ToolInvocationPart {
9
+ type: 'tool-invocation';
10
+ toolInvocation: {
11
+ toolName: string;
12
+ args: Record<string, unknown>;
13
+ };
14
+ }
15
+
16
+ interface TextPart {
17
+ type: 'text';
18
+ text: string;
19
+ }
20
+
21
+ export type MessagePart = ToolInvocationPart | TextPart | { type: string };
22
+
23
+ export interface Message {
24
+ role: string;
25
+ parts: MessagePart[];
26
+ }
27
+
28
+ /**
29
+ * Extract file paths from conversation messages for conditional rule filtering.
30
+ * Parses tool call arguments and scans message content for path-like strings.
31
+ *
32
+ * @param messages - Array of conversation messages
33
+ * @returns Deduplicated array of file paths found in messages
34
+ */
35
+ export function extractFilePathsFromMessages(messages: Message[]): string[] {
36
+ const paths = new Set<string>();
37
+
38
+ for (const message of messages) {
39
+ for (const part of message.parts) {
40
+ // Extract from tool invocations
41
+ if (part.type === 'tool-invocation') {
42
+ const toolPart = part as ToolInvocationPart;
43
+ extractPathsFromToolCall(toolPart, paths);
44
+ }
45
+
46
+ // Extract from text content
47
+ if (part.type === 'text') {
48
+ const textPart = part as TextPart;
49
+ extractPathsFromText(textPart.text, paths);
50
+ }
51
+ }
52
+ }
53
+
54
+ return Array.from(paths);
55
+ }
56
+
57
+ /**
58
+ * Extract file paths from tool call arguments
59
+ */
60
+ function extractPathsFromToolCall(
61
+ part: ToolInvocationPart,
62
+ paths: Set<string>
63
+ ): void {
64
+ const { toolName, args } = part.toolInvocation;
65
+
66
+ // Tools that have a direct file path argument
67
+ const pathArgTools: Record<string, string[]> = {
68
+ read: ['filePath'],
69
+ edit: ['filePath'],
70
+ write: ['filePath'],
71
+ glob: ['pattern', 'path'],
72
+ grep: ['path'],
73
+ };
74
+
75
+ const argNames = pathArgTools[toolName];
76
+ if (argNames) {
77
+ for (const argName of argNames) {
78
+ const value = args[argName];
79
+ if (typeof value === 'string' && value.length > 0) {
80
+ // For glob patterns, extract the directory part
81
+ if (argName === 'pattern') {
82
+ const dirPart = extractDirFromGlob(value);
83
+ if (dirPart) paths.add(dirPart);
84
+ } else {
85
+ paths.add(value);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Extract directory path from a glob pattern
94
+ */
95
+ function extractDirFromGlob(pattern: string): string | null {
96
+ // Find the first glob character
97
+ const globChars = ['*', '?', '[', '{'];
98
+ let firstGlobIndex = pattern.length;
99
+
100
+ for (const char of globChars) {
101
+ const idx = pattern.indexOf(char);
102
+ if (idx !== -1 && idx < firstGlobIndex) {
103
+ firstGlobIndex = idx;
104
+ }
105
+ }
106
+
107
+ if (firstGlobIndex === 0) return null;
108
+
109
+ // Get the directory part before the glob
110
+ const beforeGlob = pattern.substring(0, firstGlobIndex);
111
+ const lastSlash = beforeGlob.lastIndexOf('/');
112
+
113
+ if (lastSlash === -1) {
114
+ // If no slash and pattern has glob characters, it's just a file prefix, not a directory
115
+ if (firstGlobIndex < pattern.length) return null;
116
+ return beforeGlob;
117
+ }
118
+ return beforeGlob.substring(0, lastSlash);
119
+ }
120
+
121
+ /**
122
+ * Extract file paths from text content using regex
123
+ */
124
+ function extractPathsFromText(text: string, paths: Set<string>): void {
125
+ // Match paths that look like file paths:
126
+ // - Start with ./, ../, /, or a word character
127
+ // - Contain at least one /
128
+ // - End with a file extension or directory
129
+ const pathRegex =
130
+ /(?:^|[\s"'`(])((\.{0,2}\/)?[\w./-]+\/[\w./-]+(?:\.\w+)?)/gm;
131
+
132
+ let match;
133
+ while ((match = pathRegex.exec(text)) !== null) {
134
+ let potentialPath = match[1];
135
+
136
+ // Trim trailing punctuation that likely belongs to prose, not the path
137
+ potentialPath = potentialPath.replace(/[.,!?:;]+$/, '');
138
+
139
+ // Filter out URLs and other non-paths
140
+ if (
141
+ potentialPath.includes('://') ||
142
+ potentialPath.startsWith('http') ||
143
+ potentialPath.includes('@')
144
+ ) {
145
+ continue;
146
+ }
147
+
148
+ // Must have a reasonable structure (not just slashes)
149
+ if (potentialPath.replace(/[/.]/g, '').length > 0) {
150
+ paths.add(potentialPath);
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,72 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+
4
+ const SIMPLE_MARKERS: Array<[string, string]> = [
5
+ ['package.json', 'node'],
6
+ ['pyproject.toml', 'python'],
7
+ ['go.mod', 'go'],
8
+ ['Cargo.toml', 'rust'],
9
+ ['pnpm-workspace.yaml', 'monorepo'],
10
+ ['turbo.json', 'monorepo'],
11
+ ];
12
+
13
+ const BROWSER_EXTENSION_SIGNAL_KEYS = [
14
+ 'background',
15
+ 'content_scripts',
16
+ 'browser_action',
17
+ 'page_action',
18
+ 'action',
19
+ 'permissions',
20
+ ];
21
+
22
+ async function fileExists(filePath: string): Promise<boolean> {
23
+ try {
24
+ await fs.access(filePath);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function isBrowserExtensionManifest(
32
+ manifestPath: string
33
+ ): Promise<boolean> {
34
+ try {
35
+ const content = await fs.readFile(manifestPath, 'utf-8');
36
+ const parsed = JSON.parse(content);
37
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
38
+ return false;
39
+ const version = parsed.manifest_version;
40
+ if (version !== 2 && version !== 3) return false;
41
+ return BROWSER_EXTENSION_SIGNAL_KEYS.some(key => key in parsed);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export async function detectProjectTags(projectDir: string): Promise<string[]> {
48
+ const tags = new Set<string>();
49
+
50
+ const checks = SIMPLE_MARKERS.map(async ([marker, tag]) => {
51
+ const markerPath = path.join(projectDir, marker);
52
+ if (await fileExists(markerPath)) {
53
+ tags.add(tag);
54
+ }
55
+ });
56
+
57
+ const manifestPath = path.join(projectDir, 'manifest.json');
58
+ checks.push(
59
+ (async () => {
60
+ if (
61
+ (await fileExists(manifestPath)) &&
62
+ (await isBrowserExtensionManifest(manifestPath))
63
+ ) {
64
+ tags.add('browser-extension');
65
+ }
66
+ })()
67
+ );
68
+
69
+ await Promise.all(checks);
70
+
71
+ return [...tags].sort((a, b) => a.localeCompare(b));
72
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Rule file discovery utilities
3
+ */
4
+
5
+ import { stat, readFile, readdir } from 'fs/promises';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { createDebugLog } from './debug';
9
+ import {
10
+ parseRuleMetadata,
11
+ stripFrontmatter,
12
+ type RuleMetadata,
13
+ } from './rule-metadata';
14
+
15
+ const debugLog = createDebugLog();
16
+
17
+ /**
18
+ * Cached rule data for performance optimization
19
+ */
20
+ interface CachedRule {
21
+ /** Raw file content */
22
+ content: string;
23
+ /** Parsed metadata from frontmatter */
24
+ metadata: RuleMetadata | undefined;
25
+ /** Content with frontmatter stripped */
26
+ strippedContent: string;
27
+ /** File modification time for cache invalidation */
28
+ mtime: number;
29
+ }
30
+
31
+ /**
32
+ * Rule cache keyed by absolute file path
33
+ */
34
+ const ruleCache = new Map<string, CachedRule>();
35
+
36
+ /**
37
+ * Clear the rule cache (useful for testing or manual invalidation)
38
+ */
39
+ export function clearRuleCache(): void {
40
+ ruleCache.clear();
41
+ }
42
+
43
+ /**
44
+ * Get cached rule data, refreshing from disk if file has changed.
45
+ * Uses mtime-based invalidation to detect file changes.
46
+ *
47
+ * @param filePath - Absolute path to the rule file
48
+ * @returns Cached rule data or undefined if file cannot be read
49
+ */
50
+ export async function getCachedRule(
51
+ filePath: string
52
+ ): Promise<CachedRule | undefined> {
53
+ try {
54
+ const stats = await stat(filePath);
55
+ const mtime = stats.mtimeMs;
56
+
57
+ // Check if we have a valid cached entry
58
+ const cached = ruleCache.get(filePath);
59
+ if (cached && cached.mtime === mtime) {
60
+ debugLog(`Cache hit: ${filePath}`);
61
+ return cached;
62
+ }
63
+
64
+ // Read and cache the file
65
+ debugLog(`Cache miss: ${filePath}`);
66
+ const content = await readFile(filePath, 'utf-8');
67
+ const metadata = parseRuleMetadata(content);
68
+ const strippedContent = stripFrontmatter(content);
69
+
70
+ const entry: CachedRule = {
71
+ content,
72
+ metadata,
73
+ strippedContent,
74
+ mtime,
75
+ };
76
+
77
+ ruleCache.set(filePath, entry);
78
+ return entry;
79
+ } catch (error) {
80
+ // Remove stale cache entry if file no longer exists
81
+ ruleCache.delete(filePath);
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ console.warn(
84
+ `[opencode-rules-md] Warning: Failed to read rule file ${filePath}: ${message}`
85
+ );
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Get the global rules directory path
92
+ */
93
+ function getGlobalRulesDir(): string | null {
94
+ const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR;
95
+ if (opencodeConfigDir) {
96
+ return path.join(opencodeConfigDir, 'rules');
97
+ }
98
+
99
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
100
+ if (xdgConfigHome) {
101
+ return path.join(xdgConfigHome, 'opencode', 'rules');
102
+ }
103
+
104
+ const homeDir = process.env.HOME || os.homedir();
105
+ return path.join(homeDir, '.config', 'opencode', 'rules');
106
+ }
107
+
108
+ /**
109
+ * Recursively scan a directory for markdown rule files
110
+ * Skips hidden files and directories (starting with .)
111
+ * @param dir - Directory to scan
112
+ * @param baseDir - Base directory for relative path calculation
113
+ * @returns Array of discovered file paths with their relative paths from baseDir
114
+ */
115
+ async function scanDirectoryRecursively(
116
+ dir: string,
117
+ baseDir: string
118
+ ): Promise<Array<{ filePath: string; relativePath: string }>> {
119
+ const results: Array<{ filePath: string; relativePath: string }> = [];
120
+
121
+ try {
122
+ const entries = await readdir(dir, { withFileTypes: true });
123
+ for (const entry of entries) {
124
+ // Skip hidden files and directories
125
+ if (entry.name.startsWith('.')) {
126
+ continue;
127
+ }
128
+
129
+ const fullPath = path.join(dir, entry.name);
130
+
131
+ if (entry.isDirectory()) {
132
+ // Recurse into subdirectory
133
+ results.push(...(await scanDirectoryRecursively(fullPath, baseDir)));
134
+ } else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdc')) {
135
+ // Add markdown file
136
+ const relativePath = path.relative(baseDir, fullPath);
137
+ results.push({ filePath: fullPath, relativePath });
138
+ }
139
+ }
140
+ } catch (error) {
141
+ // Treat ENOENT as benign (directory doesn't exist or was deleted)
142
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
143
+ return results;
144
+ }
145
+ // Log non-ENOENT directory read errors
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ console.warn(
148
+ `[opencode-rules-md] Warning: Failed to read directory ${dir}: ${message}`
149
+ );
150
+ }
151
+
152
+ return results;
153
+ }
154
+
155
+ /**
156
+ * Discovered rule file with both absolute and relative paths
157
+ */
158
+ export interface DiscoveredRule {
159
+ /** Absolute path to the rule file */
160
+ filePath: string;
161
+ /** Relative path from the rules directory root (for unique headings) */
162
+ relativePath: string;
163
+ }
164
+
165
+ /**
166
+ * Discover markdown rule files from standard directories
167
+ * Searches recursively in:
168
+ * - $OPENCODE_CONFIG_DIR/rules/ (highest priority)
169
+ * - $XDG_CONFIG_HOME/opencode/rules/ (or ~/.config/opencode/rules as fallback)
170
+ * - .opencode/rules/ (in project directory if provided)
171
+ * Finds all .md and .mdc files including nested subdirectories.
172
+ */
173
+ export async function discoverRuleFiles(
174
+ projectDir?: string
175
+ ): Promise<DiscoveredRule[]> {
176
+ const files: DiscoveredRule[] = [];
177
+
178
+ // Discover global rules (recursively)
179
+ const globalRulesDir = getGlobalRulesDir();
180
+ if (globalRulesDir) {
181
+ const globalRules = await scanDirectoryRecursively(
182
+ globalRulesDir,
183
+ globalRulesDir
184
+ );
185
+ for (const { filePath, relativePath } of globalRules) {
186
+ debugLog(`Discovered global rule: ${relativePath} (${filePath})`);
187
+ files.push({ filePath, relativePath });
188
+ }
189
+ }
190
+
191
+ // Discover project-local rules (recursively) if project directory is provided
192
+ if (projectDir) {
193
+ const projectRulesDir = path.join(projectDir, '.opencode', 'rules');
194
+ const projectRules = await scanDirectoryRecursively(
195
+ projectRulesDir,
196
+ projectRulesDir
197
+ );
198
+ for (const { filePath, relativePath } of projectRules) {
199
+ debugLog(`Discovered project rule: ${relativePath} (${filePath})`);
200
+ files.push({ filePath, relativePath });
201
+ }
202
+ }
203
+
204
+ return files;
205
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Rule filtering and matching utilities
3
+ */
4
+
5
+ import { minimatch } from 'minimatch';
6
+ import { createDebugLog } from './debug.js';
7
+ import { getCachedRule, type DiscoveredRule } from './rule-discovery.js';
8
+
9
+ const debugLog = createDebugLog();
10
+
11
+ /**
12
+ * Check if a file path matches any of the given glob patterns
13
+ */
14
+ function fileMatchesGlobs(filePath: string, globs: string[]): boolean {
15
+ return globs.some(glob => minimatch(filePath, glob, { matchBase: true }));
16
+ }
17
+
18
+ /**
19
+ * Check if a user prompt matches any of the given keywords.
20
+ * Uses case-insensitive word-boundary matching.
21
+ *
22
+ * @param prompt - The user's prompt text
23
+ * @param keywords - Array of keywords to match
24
+ * @returns true if any keyword matches the prompt
25
+ */
26
+ export function promptMatchesKeywords(
27
+ prompt: string,
28
+ keywords: string[]
29
+ ): boolean {
30
+ const lowerPrompt = prompt.toLowerCase();
31
+
32
+ return keywords.some(keyword => {
33
+ const lowerKeyword = keyword.toLowerCase();
34
+ // Escape special regex characters in the keyword
35
+ const escaped = lowerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ // Word boundary at start, but allow continuation at end (e.g., "test" matches "testing")
37
+ const regex = new RegExp(`\\b${escaped}`, 'i');
38
+ return regex.test(lowerPrompt);
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Check if any of the required tools are available.
44
+ * Uses exact string matching (OR logic: any match returns true).
45
+ *
46
+ * @param availableToolIDs - Array of tool IDs currently available
47
+ * @param requiredTools - Array of tool IDs from rule metadata
48
+ * @returns true if any required tool is available
49
+ */
50
+ export function toolsMatchAvailable(
51
+ availableToolIDs: string[],
52
+ requiredTools: string[]
53
+ ): boolean {
54
+ if (requiredTools.length === 0) {
55
+ return false;
56
+ }
57
+ // Create a Set for O(1) lookups
58
+ const availableSet = new Set(availableToolIDs);
59
+ return requiredTools.some(tool => availableSet.has(tool));
60
+ }
61
+
62
+ /**
63
+ * Result of reading and formatting rules
64
+ */
65
+ export interface FilterResult {
66
+ formattedRules: string;
67
+ matchedPaths: string[];
68
+ }
69
+
70
+ /**
71
+ * Runtime filter context for conditional rule matching
72
+ */
73
+ export interface RuleFilterContext {
74
+ /** File paths from conversation context (for glob matching) */
75
+ contextFilePaths?: string[];
76
+ /** User's prompt text (for keyword matching) */
77
+ userPrompt?: string;
78
+ /** Available tool IDs (for tool-based filtering) */
79
+ availableToolIDs?: string[];
80
+ /** Current model ID */
81
+ modelID?: string;
82
+ /** Current agent type */
83
+ agentType?: string;
84
+ /** Current slash command (e.g., /plan, /review) */
85
+ command?: string;
86
+ /** Detected project tags (e.g., node, python, monorepo) */
87
+ projectTags?: string[];
88
+ /** Current git branch name */
89
+ gitBranch?: string;
90
+ /** Current operating system (e.g., linux, darwin, win32) */
91
+ os?: string;
92
+ /** Whether running in CI environment */
93
+ ci?: boolean;
94
+ }
95
+
96
+ /**
97
+ * Read and format rule files for system prompt injection
98
+ * @param files - Array of discovered rule files with paths
99
+ * @param context - Optional RuleFilterContext for conditional rule matching
100
+ */
101
+ export async function readAndFormatRules(
102
+ files: DiscoveredRule[],
103
+ context: RuleFilterContext = {}
104
+ ): Promise<FilterResult> {
105
+ if (files.length === 0) {
106
+ return { formattedRules: '', matchedPaths: [] };
107
+ }
108
+
109
+ const ruleContents: string[] = [];
110
+ const matchedPaths: string[] = [];
111
+ const availableToolSet =
112
+ context.availableToolIDs && context.availableToolIDs.length > 0
113
+ ? new Set(context.availableToolIDs)
114
+ : undefined;
115
+
116
+ for (const { filePath, relativePath } of files) {
117
+ // Use cached rule data with mtime-based invalidation
118
+ const cachedRule = await getCachedRule(filePath);
119
+ if (!cachedRule) {
120
+ continue; // Error already logged by getCachedRule
121
+ }
122
+
123
+ const { metadata, strippedContent } = cachedRule;
124
+
125
+ // Check if rule has any conditional filters
126
+ const hasConditions = Boolean(
127
+ metadata?.globs ||
128
+ metadata?.keywords ||
129
+ metadata?.tools ||
130
+ metadata?.model ||
131
+ metadata?.agent ||
132
+ metadata?.command ||
133
+ metadata?.project ||
134
+ metadata?.branch ||
135
+ metadata?.os ||
136
+ metadata?.ci !== undefined
137
+ );
138
+
139
+ if (hasConditions && metadata) {
140
+ // Compute per-dimension match booleans (only for declared conditions)
141
+ const declaredChecks: boolean[] = [];
142
+
143
+ // Legacy: globs
144
+ if (metadata.globs) {
145
+ const globs = metadata.globs;
146
+ const globsMatch =
147
+ context.contextFilePaths &&
148
+ context.contextFilePaths.length > 0 &&
149
+ context.contextFilePaths.some(contextPath =>
150
+ fileMatchesGlobs(contextPath, globs)
151
+ );
152
+ declaredChecks.push(Boolean(globsMatch));
153
+ }
154
+
155
+ // Legacy: keywords
156
+ if (metadata.keywords) {
157
+ const keywordsMatch =
158
+ context.userPrompt &&
159
+ promptMatchesKeywords(context.userPrompt, metadata.keywords);
160
+ declaredChecks.push(Boolean(keywordsMatch));
161
+ }
162
+
163
+ // Legacy: tools
164
+ if (metadata.tools) {
165
+ const toolsMatch =
166
+ availableToolSet &&
167
+ metadata.tools.some(tool => availableToolSet.has(tool));
168
+ declaredChecks.push(Boolean(toolsMatch));
169
+ }
170
+
171
+ // New: model
172
+ if (metadata.model) {
173
+ const modelMatch =
174
+ context.modelID && metadata.model.includes(context.modelID);
175
+ declaredChecks.push(Boolean(modelMatch));
176
+ }
177
+
178
+ // New: agent
179
+ if (metadata.agent) {
180
+ const agentMatch =
181
+ context.agentType && metadata.agent.includes(context.agentType);
182
+ declaredChecks.push(Boolean(agentMatch));
183
+ }
184
+
185
+ // New: command
186
+ if (metadata.command) {
187
+ const commandMatch =
188
+ context.command && metadata.command.includes(context.command);
189
+ declaredChecks.push(Boolean(commandMatch));
190
+ }
191
+
192
+ // New: project
193
+ if (metadata.project) {
194
+ const projectTags = context.projectTags;
195
+ const projectMatch =
196
+ projectTags &&
197
+ projectTags.length > 0 &&
198
+ metadata.project.some(tag => projectTags.includes(tag));
199
+ declaredChecks.push(Boolean(projectMatch));
200
+ }
201
+
202
+ // New: branch (supports glob patterns)
203
+ if (metadata.branch) {
204
+ const gitBranch = context.gitBranch;
205
+ const branchMatch =
206
+ gitBranch &&
207
+ metadata.branch.some(pattern => {
208
+ // Exact match for non-glob patterns
209
+ if (pattern === gitBranch) {
210
+ return true;
211
+ }
212
+ // Only use glob matching if pattern contains glob characters
213
+ const hasGlobChars = /[*?\[{]/.test(pattern);
214
+ if (hasGlobChars) {
215
+ return minimatch(gitBranch, pattern);
216
+ }
217
+ return false;
218
+ });
219
+ declaredChecks.push(Boolean(branchMatch));
220
+ }
221
+
222
+ // New: os
223
+ if (metadata.os) {
224
+ const osMatch = context.os && metadata.os.includes(context.os);
225
+ declaredChecks.push(Boolean(osMatch));
226
+ }
227
+
228
+ // New: ci (strict boolean equality)
229
+ if (metadata.ci !== undefined) {
230
+ const ciMatch = context.ci === metadata.ci;
231
+ declaredChecks.push(ciMatch);
232
+ }
233
+
234
+ // Apply combinator: default 'any', or 'all' if specified
235
+ const mode = metadata.match ?? 'any';
236
+ const shouldInclude =
237
+ mode === 'all'
238
+ ? declaredChecks.every(Boolean)
239
+ : declaredChecks.some(Boolean);
240
+
241
+ if (!shouldInclude) {
242
+ debugLog(
243
+ `Skipping conditional rule: ${relativePath} (match: ${mode}, checks: ${declaredChecks.join(', ')})`
244
+ );
245
+ continue;
246
+ }
247
+
248
+ debugLog(
249
+ `Including conditional rule: ${relativePath} (match: ${mode}, checks: ${declaredChecks.join(', ')})`
250
+ );
251
+ }
252
+
253
+ // Use cached stripped content for output
254
+ // Use relativePath for unique headings instead of just filename
255
+ ruleContents.push(`## ${relativePath}\n\n${strippedContent}`);
256
+ matchedPaths.push(filePath);
257
+ }
258
+
259
+ if (ruleContents.length === 0) {
260
+ return { formattedRules: '', matchedPaths: [] };
261
+ }
262
+
263
+ return {
264
+ formattedRules:
265
+ `# OpenCode Rules\n\nPlease follow the following rules:\n\n` +
266
+ ruleContents.join('\n\n---\n\n'),
267
+ matchedPaths,
268
+ };
269
+ }