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.
- package/LICENSE +21 -0
- package/README.md +650 -0
- package/dist/cli.mjs +374 -0
- package/dist/src/active-rules-state.d.ts +12 -0
- package/dist/src/active-rules-state.d.ts.map +1 -0
- package/dist/src/active-rules-state.js +118 -0
- package/dist/src/active-rules-state.js.map +1 -0
- package/dist/src/cli/config.d.ts +62 -0
- package/dist/src/cli/config.d.ts.map +1 -0
- package/dist/src/cli/config.js +246 -0
- package/dist/src/cli/config.js.map +1 -0
- package/dist/src/cli/install.d.ts +21 -0
- package/dist/src/cli/install.d.ts.map +1 -0
- package/dist/src/cli/install.js +63 -0
- package/dist/src/cli/install.js.map +1 -0
- package/dist/src/cli/main.d.ts +19 -0
- package/dist/src/cli/main.d.ts.map +1 -0
- package/dist/src/cli/main.js +135 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/real-fs.d.ts +26 -0
- package/dist/src/cli/real-fs.d.ts.map +1 -0
- package/dist/src/cli/real-fs.js +42 -0
- package/dist/src/cli/real-fs.js.map +1 -0
- package/dist/src/cli/status.d.ts +19 -0
- package/dist/src/cli/status.d.ts.map +1 -0
- package/dist/src/cli/status.js +58 -0
- package/dist/src/cli/status.js.map +1 -0
- package/dist/src/debug.d.ts +3 -0
- package/dist/src/debug.d.ts.map +1 -0
- package/dist/src/debug.js +8 -0
- package/dist/src/debug.js.map +1 -0
- package/dist/src/git-branch.d.ts +2 -0
- package/dist/src/git-branch.d.ts.map +1 -0
- package/dist/src/git-branch.js +30 -0
- package/dist/src/git-branch.js.map +1 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +55 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcp-tools.d.ts +8 -0
- package/dist/src/mcp-tools.d.ts.map +1 -0
- package/dist/src/mcp-tools.js +18 -0
- package/dist/src/mcp-tools.js.map +1 -0
- package/dist/src/message-context.d.ts +55 -0
- package/dist/src/message-context.d.ts.map +1 -0
- package/dist/src/message-context.js +110 -0
- package/dist/src/message-context.js.map +1 -0
- package/dist/src/message-paths.d.ts +34 -0
- package/dist/src/message-paths.d.ts.map +1 -0
- package/dist/src/message-paths.js +112 -0
- package/dist/src/message-paths.js.map +1 -0
- package/dist/src/project-fingerprint.d.ts +2 -0
- package/dist/src/project-fingerprint.d.ts.map +1 -0
- package/dist/src/project-fingerprint.js +61 -0
- package/dist/src/project-fingerprint.js.map +1 -0
- package/dist/src/rule-discovery.d.ts +49 -0
- package/dist/src/rule-discovery.d.ts.map +1 -0
- package/dist/src/rule-discovery.js +143 -0
- package/dist/src/rule-discovery.js.map +1 -0
- package/dist/src/rule-filter.d.ts +61 -0
- package/dist/src/rule-filter.d.ts.map +1 -0
- package/dist/src/rule-filter.js +180 -0
- package/dist/src/rule-filter.js.map +1 -0
- package/dist/src/rule-metadata.d.ts +29 -0
- package/dist/src/rule-metadata.d.ts.map +1 -0
- package/dist/src/rule-metadata.js +100 -0
- package/dist/src/rule-metadata.js.map +1 -0
- package/dist/src/runtime-chat.d.ts +34 -0
- package/dist/src/runtime-chat.d.ts.map +1 -0
- package/dist/src/runtime-chat.js +39 -0
- package/dist/src/runtime-chat.js.map +1 -0
- package/dist/src/runtime-context.d.ts +25 -0
- package/dist/src/runtime-context.d.ts.map +1 -0
- package/dist/src/runtime-context.js +109 -0
- package/dist/src/runtime-context.js.map +1 -0
- package/dist/src/runtime.d.ts +31 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +238 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/session-store.d.ts +34 -0
- package/dist/src/session-store.d.ts.map +1 -0
- package/dist/src/session-store.js +90 -0
- package/dist/src/session-store.js.map +1 -0
- package/dist/src/utils.d.ts +15 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +19 -0
- package/dist/src/utils.js.map +1 -0
- package/dist/tui/data/rules.d.ts +63 -0
- package/dist/tui/data/rules.d.ts.map +1 -0
- package/dist/tui/data/rules.js +173 -0
- package/dist/tui/data/rules.js.map +1 -0
- package/dist/tui/index.d.ts +8 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +13 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/slots/sidebar-content.d.ts +11 -0
- package/dist/tui/slots/sidebar-content.d.ts.map +1 -0
- package/dist/tui/slots/sidebar-content.js +150 -0
- package/dist/tui/slots/sidebar-content.js.map +1 -0
- package/package.json +101 -0
- package/src/active-rules-state.ts +166 -0
- package/src/cli/config.ts +312 -0
- package/src/cli/install.ts +94 -0
- package/src/cli/main.ts +167 -0
- package/src/cli/real-fs.ts +65 -0
- package/src/cli/status.ts +80 -0
- package/src/debug.ts +9 -0
- package/src/git-branch.ts +37 -0
- package/src/index.ts +68 -0
- package/src/mcp-tools.ts +24 -0
- package/src/message-context.ts +139 -0
- package/src/message-paths.ts +153 -0
- package/src/project-fingerprint.ts +72 -0
- package/src/rule-discovery.ts +205 -0
- package/src/rule-filter.ts +269 -0
- package/src/rule-metadata.ts +161 -0
- package/src/runtime-chat.ts +68 -0
- package/src/runtime-context.ts +136 -0
- package/src/runtime.ts +369 -0
- package/src/session-store.ts +129 -0
- package/src/utils.ts +36 -0
- package/tui/.atl/.skill-registry.cache.json +3 -0
- package/tui/.atl/skill-registry.md +57 -0
- package/tui/data/rules.ts +230 -0
- package/tui/index.tsx +23 -0
- package/tui/slots/sidebar-content.tsx +364 -0
- 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
|
+
}
|