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
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entrypoint for opencode-rules-md installer.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* install — add opencode-rules-md to the global opencode config
|
|
7
|
+
* status — report whether opencode-rules-md is installed
|
|
8
|
+
*
|
|
9
|
+
* Flags:
|
|
10
|
+
* --version <v> — specify a plugin version to install
|
|
11
|
+
* --latest — install the latest version (default)
|
|
12
|
+
* --dry-run — show what would be written, without changing files
|
|
13
|
+
* --yes — skip confirmation prompts (future use)
|
|
14
|
+
* -h, --help — show this help text
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { parseArgs } from 'node:util';
|
|
18
|
+
import { runInstall } from './install.js';
|
|
19
|
+
import { runStatus } from './status.js';
|
|
20
|
+
import type { CliFs } from './real-fs.js';
|
|
21
|
+
import { realFs } from './real-fs.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Help text
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const HELP_TEXT = `opencode-rules-md CLI
|
|
28
|
+
|
|
29
|
+
Usage: opencode-rules-md <command> [options]
|
|
30
|
+
|
|
31
|
+
Commands:
|
|
32
|
+
install Add opencode-rules-md to the global opencode config
|
|
33
|
+
status Report whether opencode-rules-md is installed
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--version <v> Specify a plugin version to install
|
|
37
|
+
--latest Install the latest version (default)
|
|
38
|
+
--dry-run Show what would be written, without changing files
|
|
39
|
+
--yes Skip confirmation prompts (future use)
|
|
40
|
+
-h, --help Show this help text
|
|
41
|
+
`.trim();
|
|
42
|
+
|
|
43
|
+
const USAGE_ERROR_TEXT = `Error: unknown command. Run 'opencode-rules-md --help' for usage.
|
|
44
|
+
`.trim();
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Argument parsing
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
interface CliOptions {
|
|
51
|
+
version?: string;
|
|
52
|
+
latest?: boolean;
|
|
53
|
+
dryRun?: boolean;
|
|
54
|
+
yes?: boolean;
|
|
55
|
+
help?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseCliArgs(argv: string[]): { command: string | null; options: CliOptions; unknownFlags: string[] } {
|
|
59
|
+
const { positionals, values } = parseArgs({
|
|
60
|
+
args: argv,
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
options: {
|
|
63
|
+
version: { type: 'string' },
|
|
64
|
+
latest: { type: 'boolean' },
|
|
65
|
+
'dry-run': { type: 'boolean' },
|
|
66
|
+
yes: { type: 'boolean' },
|
|
67
|
+
h: { type: 'boolean' },
|
|
68
|
+
help: { type: 'boolean' },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Extract command (first positional)
|
|
73
|
+
const command = positionals[0] ?? null;
|
|
74
|
+
|
|
75
|
+
// Extract options from parsed values
|
|
76
|
+
// Note: -h sets key 'h', --help sets key 'help'
|
|
77
|
+
const options: CliOptions = {
|
|
78
|
+
...(values.version !== undefined && { version: values.version as string }),
|
|
79
|
+
...(values.latest && { latest: true }),
|
|
80
|
+
...(values['dry-run'] && { dryRun: true }),
|
|
81
|
+
...(values.yes && { yes: true }),
|
|
82
|
+
...((values.help || values.h) && { help: true }),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Detect unknown flags (tokens that look like flags but aren't recognized)
|
|
86
|
+
// parseArgs already filters known tokens, so we check if any positional
|
|
87
|
+
// looks like an unknown option
|
|
88
|
+
const unknownFlags: string[] = [];
|
|
89
|
+
|
|
90
|
+
return { command, options, unknownFlags };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Dispatcher
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export type ExitCode = 0 | 1 | 2;
|
|
98
|
+
|
|
99
|
+
export async function runMain(argv: string[], fs: CliFs = realFs): Promise<ExitCode> {
|
|
100
|
+
try {
|
|
101
|
+
const { command, options, unknownFlags } = parseCliArgs(argv);
|
|
102
|
+
|
|
103
|
+
// Handle help flag at top level (before command dispatch)
|
|
104
|
+
if (options.help) {
|
|
105
|
+
console.log(HELP_TEXT);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Handle unknown flags
|
|
110
|
+
if (unknownFlags.length > 0) {
|
|
111
|
+
console.error(`Error: unknown option(s): ${unknownFlags.join(', ')}`);
|
|
112
|
+
console.error(USAGE_ERROR_TEXT);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// No command given
|
|
117
|
+
if (!command) {
|
|
118
|
+
console.error(USAGE_ERROR_TEXT);
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
switch (command) {
|
|
123
|
+
case 'install': {
|
|
124
|
+
const installOpts: { version?: string; dryRun?: boolean } = {};
|
|
125
|
+
if (options.version !== undefined) installOpts.version = options.version;
|
|
126
|
+
if (options.dryRun) installOpts.dryRun = true;
|
|
127
|
+
const installResult = runInstall(installOpts, fs);
|
|
128
|
+
// Map install status to exit code
|
|
129
|
+
if (installResult.status === 'error') {
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case 'status': {
|
|
136
|
+
const statusResult = runStatus(fs);
|
|
137
|
+
// Print status to stdout
|
|
138
|
+
if (statusResult.installed) {
|
|
139
|
+
console.log(`opencode-rules-md is installed (${statusResult.specifier})`);
|
|
140
|
+
} else {
|
|
141
|
+
console.log('opencode-rules-md is not installed');
|
|
142
|
+
}
|
|
143
|
+
// Exit code 0 regardless of install state (status is read-only)
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
default: {
|
|
148
|
+
console.error(`Error: unknown command '${command}'`);
|
|
149
|
+
console.error(USAGE_ERROR_TEXT);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Entry point
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
// Only run if executed directly (not imported as a module)
|
|
164
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
165
|
+
const exitCode = await runMain(process.argv.slice(2));
|
|
166
|
+
process.exit(exitCode);
|
|
167
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin synchronous adapter over node:fs that implements the CliFs interface.
|
|
3
|
+
* All production CLI disk I/O flows through this module — no direct node:fs
|
|
4
|
+
* calls outside this file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as nodePath from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Synchronous filesystem interface used by the CLI.
|
|
12
|
+
* Tests supply an in-memory implementation; production uses this adapter.
|
|
13
|
+
*/
|
|
14
|
+
export interface CliFs {
|
|
15
|
+
readFileSync(path: string): string;
|
|
16
|
+
writeFileSync(path: string, content: string, encoding?: string): void;
|
|
17
|
+
renameSync(from: string, to: string): void;
|
|
18
|
+
copyFileSync(from: string, to: string): void;
|
|
19
|
+
unlinkSync(path: string): void;
|
|
20
|
+
mkdirSync(path: string, opts?: { recursive?: boolean }): void;
|
|
21
|
+
readdirSync(path: string): string[];
|
|
22
|
+
existsSync(path: string): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Production CliFs implementation backed by node:fs (synchronous).
|
|
27
|
+
*/
|
|
28
|
+
export const realFs: CliFs = {
|
|
29
|
+
readFileSync(filePath: string): string {
|
|
30
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
writeFileSync(filePath: string, content: string, _encoding?: string): void {
|
|
34
|
+
// Ensure parent directory exists before writing
|
|
35
|
+
const dir = nodePath.dirname(filePath);
|
|
36
|
+
if (!fs.existsSync(dir)) {
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
renameSync(from: string, to: string): void {
|
|
43
|
+
fs.renameSync(from, to);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
copyFileSync(from: string, to: string): void {
|
|
47
|
+
fs.copyFileSync(from, to);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
unlinkSync(filePath: string): void {
|
|
51
|
+
fs.unlinkSync(filePath);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
mkdirSync(filePath: string, opts?: { recursive?: boolean }): void {
|
|
55
|
+
fs.mkdirSync(filePath, opts);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
readdirSync(filePath: string): string[] {
|
|
59
|
+
return fs.readdirSync(filePath);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
existsSync(filePath: string): boolean {
|
|
63
|
+
return fs.existsSync(filePath);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status command: read-only probe that reports install state.
|
|
3
|
+
*
|
|
4
|
+
* Reports:
|
|
5
|
+
* - installed: yes/no
|
|
6
|
+
* - specifier: the registered opencode-rules-md entry (or undefined)
|
|
7
|
+
* - path: config file path
|
|
8
|
+
* - version: bundled CLI version from package.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { loadGlobalConfig } from './config.js';
|
|
12
|
+
import type { CliFs } from './real-fs.js';
|
|
13
|
+
import { realFs } from './real-fs.js';
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Result types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface StatusResult {
|
|
22
|
+
installed: boolean;
|
|
23
|
+
path: string;
|
|
24
|
+
specifier?: string;
|
|
25
|
+
version: string;
|
|
26
|
+
parseError?: Error;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Run status
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export function runStatus(fs: CliFs = realFs): StatusResult {
|
|
34
|
+
const loadResult = loadGlobalConfig(fs);
|
|
35
|
+
const configPath = loadResult.path;
|
|
36
|
+
|
|
37
|
+
// Surface parse errors as installed=false
|
|
38
|
+
if (loadResult.parseError) {
|
|
39
|
+
return {
|
|
40
|
+
installed: false,
|
|
41
|
+
path: configPath,
|
|
42
|
+
version: getVersion(),
|
|
43
|
+
parseError: loadResult.parseError,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const config = loadResult.config;
|
|
48
|
+
const pluginList: string[] = Array.isArray(config['plugin']) ? (config['plugin'] as string[]) : [];
|
|
49
|
+
|
|
50
|
+
// Find the opencode-rules-md entry (exact match for idempotency)
|
|
51
|
+
const specifier = pluginList.find(p => p.startsWith('opencode-rules-md'));
|
|
52
|
+
|
|
53
|
+
const result: StatusResult = {
|
|
54
|
+
installed: specifier !== undefined,
|
|
55
|
+
path: configPath,
|
|
56
|
+
version: getVersion(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (specifier) {
|
|
60
|
+
result.specifier = specifier;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Internal helpers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
function getVersion(): string {
|
|
71
|
+
try {
|
|
72
|
+
// Read package.json relative to the project root
|
|
73
|
+
const pkgPath = path.resolve(process.cwd(), 'package.json');
|
|
74
|
+
const content = fs.readFileSync(pkgPath, 'utf-8');
|
|
75
|
+
const pkg = JSON.parse(content) as { version?: string };
|
|
76
|
+
return pkg.version ?? 'unknown';
|
|
77
|
+
} catch {
|
|
78
|
+
return 'unknown';
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFile, type ExecFileOptions } from 'child_process';
|
|
2
|
+
|
|
3
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
4
|
+
|
|
5
|
+
export async function getGitBranch(
|
|
6
|
+
projectDir: string
|
|
7
|
+
): Promise<string | undefined> {
|
|
8
|
+
try {
|
|
9
|
+
const branch = await new Promise<string | undefined>(resolve => {
|
|
10
|
+
const opts: ExecFileOptions = {
|
|
11
|
+
cwd: projectDir,
|
|
12
|
+
timeout: GIT_TIMEOUT_MS,
|
|
13
|
+
killSignal: 'SIGTERM',
|
|
14
|
+
};
|
|
15
|
+
execFile(
|
|
16
|
+
'git',
|
|
17
|
+
['rev-parse', '--abbrev-ref', 'HEAD'],
|
|
18
|
+
opts,
|
|
19
|
+
(error, stdout) => {
|
|
20
|
+
if (error) {
|
|
21
|
+
resolve(undefined);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const trimmed = String(stdout).trim();
|
|
25
|
+
if (!trimmed || trimmed === 'HEAD') {
|
|
26
|
+
resolve(undefined);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
resolve(trimmed);
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
return branch;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Rules Plugin
|
|
3
|
+
*
|
|
4
|
+
* Discovers markdown rule files and injects them into the system prompt.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Plugin, PluginInput } from '@opencode-ai/plugin';
|
|
8
|
+
import { discoverRuleFiles } from './utils.js';
|
|
9
|
+
import { OpenCodeRulesRuntime } from './runtime.js';
|
|
10
|
+
import { createSessionStore, type SessionState } from './session-store.js';
|
|
11
|
+
|
|
12
|
+
const sessionStore = createSessionStore();
|
|
13
|
+
import { createDebugLog } from './debug.js';
|
|
14
|
+
|
|
15
|
+
const debugLog = createDebugLog();
|
|
16
|
+
|
|
17
|
+
const openCodeRulesPlugin = async (pluginInput: PluginInput) => {
|
|
18
|
+
const ruleFiles = await discoverRuleFiles(pluginInput.directory);
|
|
19
|
+
debugLog(`Discovered ${ruleFiles.length} rule file(s)`);
|
|
20
|
+
|
|
21
|
+
const runtime = new OpenCodeRulesRuntime({
|
|
22
|
+
client: pluginInput.client,
|
|
23
|
+
directory: pluginInput.directory,
|
|
24
|
+
projectDirectory: pluginInput.directory,
|
|
25
|
+
ruleFiles,
|
|
26
|
+
sessionStore,
|
|
27
|
+
debugLog,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return runtime.createHooks();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Test-only exports for accessing internal state and functions.
|
|
35
|
+
* @internal - Test utilities only. Not part of public API.
|
|
36
|
+
*/
|
|
37
|
+
// NOTE: OpenCode's plugin loader calls every named export as a plugin initializer.
|
|
38
|
+
// To avoid runtime crashes, __testOnly must be callable.
|
|
39
|
+
const __testOnly = Object.freeze(
|
|
40
|
+
Object.assign(async () => ({}), {
|
|
41
|
+
setSessionStateLimit: (limit: number): void => {
|
|
42
|
+
sessionStore.setMax(limit);
|
|
43
|
+
},
|
|
44
|
+
getSessionStateIDs: (): string[] => {
|
|
45
|
+
return sessionStore.ids();
|
|
46
|
+
},
|
|
47
|
+
getSessionStateSnapshot: (sessionID: string): SessionState | undefined => {
|
|
48
|
+
return sessionStore.snapshot(sessionID);
|
|
49
|
+
},
|
|
50
|
+
upsertSessionState: (
|
|
51
|
+
sessionID: string,
|
|
52
|
+
mutator: (state: SessionState) => void
|
|
53
|
+
): void => {
|
|
54
|
+
sessionStore.upsert(sessionID, mutator);
|
|
55
|
+
},
|
|
56
|
+
resetSessionState: (): void => {
|
|
57
|
+
sessionStore.reset();
|
|
58
|
+
},
|
|
59
|
+
getSeedCount: (sessionID: string): number => {
|
|
60
|
+
return sessionStore.get(sessionID)?.seedCount ?? 0;
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const id = 'opencode-rules-md' as const;
|
|
66
|
+
const server = openCodeRulesPlugin satisfies Plugin;
|
|
67
|
+
export default { id, server };
|
|
68
|
+
export { __testOnly };
|
package/src/mcp-tools.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface McpStatusMap {
|
|
2
|
+
[clientName: string]: { status?: string } | undefined;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function sanitizeMcpClientName(name: string): string {
|
|
6
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractConnectedMcpCapabilityIDs(
|
|
10
|
+
status: McpStatusMap | null | undefined
|
|
11
|
+
): string[] {
|
|
12
|
+
if (!status || typeof status !== 'object' || Array.isArray(status)) return [];
|
|
13
|
+
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
for (const [clientName, clientStatus] of Object.entries(status)) {
|
|
16
|
+
if (clientStatus?.status === 'connected') {
|
|
17
|
+
const sanitized = sanitizeMcpClientName(clientName);
|
|
18
|
+
if (sanitized) {
|
|
19
|
+
out.push(`mcp_${sanitized}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import type { Message, MessagePart } from './message-paths.js';
|
|
3
|
+
|
|
4
|
+
export interface MessagePartWithSession {
|
|
5
|
+
type?: string;
|
|
6
|
+
text?: string;
|
|
7
|
+
sessionID?: string;
|
|
8
|
+
synthetic?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MessageWithInfo {
|
|
12
|
+
role?: string;
|
|
13
|
+
parts?: MessagePartWithSession[];
|
|
14
|
+
info?: {
|
|
15
|
+
sessionID?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract and join text content from message parts.
|
|
21
|
+
* Skips synthetic parts and parts without text content.
|
|
22
|
+
* Returns an empty string if no text is extracted.
|
|
23
|
+
*/
|
|
24
|
+
export function extractTextFromParts(
|
|
25
|
+
parts: Array<{ type?: string; text?: string; synthetic?: boolean }>
|
|
26
|
+
): string {
|
|
27
|
+
const textParts: string[] = [];
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
if (part.synthetic) continue;
|
|
30
|
+
|
|
31
|
+
if (part.type === 'text' && part.text) {
|
|
32
|
+
textParts.push(part.text);
|
|
33
|
+
} else if (typeof part.text === 'string' && !part.type) {
|
|
34
|
+
textParts.push(part.text);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return textParts
|
|
39
|
+
.map(t => t.trim())
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join(' ')
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize paths to repo-relative POSIX format.
|
|
47
|
+
* If path is absolute and under baseDir, convert to relative POSIX path.
|
|
48
|
+
* Otherwise return path as-is.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeContextPath(p: string, baseDir: string): string {
|
|
51
|
+
if (!path.isAbsolute(p)) return p;
|
|
52
|
+
const rel = path.relative(baseDir, p);
|
|
53
|
+
return rel.split(path.sep).join('/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sanitize a file path for safe inclusion in context strings.
|
|
58
|
+
* Prevents prompt injection by removing control characters and limiting length.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizePathForContext(p: string): string {
|
|
61
|
+
return p.replace(/[\r\n\t]/g, ' ').slice(0, 300);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract sessionID from messages array.
|
|
66
|
+
*/
|
|
67
|
+
export function extractSessionID(
|
|
68
|
+
messages: MessageWithInfo[]
|
|
69
|
+
): string | undefined {
|
|
70
|
+
for (const message of messages) {
|
|
71
|
+
if (message.info?.sessionID) {
|
|
72
|
+
return message.info.sessionID;
|
|
73
|
+
}
|
|
74
|
+
if (message.parts) {
|
|
75
|
+
for (const part of message.parts) {
|
|
76
|
+
if (part.sessionID) {
|
|
77
|
+
return part.sessionID;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract the latest user message text from messages array.
|
|
87
|
+
*/
|
|
88
|
+
export function extractLatestUserPrompt(
|
|
89
|
+
messages: MessageWithInfo[]
|
|
90
|
+
): string | undefined {
|
|
91
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
92
|
+
const message = messages[i];
|
|
93
|
+
if (message.role && message.role !== 'user') continue;
|
|
94
|
+
const parts = message.parts || [];
|
|
95
|
+
|
|
96
|
+
const userPrompt = extractTextFromParts(parts);
|
|
97
|
+
if (userPrompt) {
|
|
98
|
+
return userPrompt;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Convert MessageWithInfo[] to Message[] by filtering out messages
|
|
107
|
+
* that lack required fields (role, non-empty parts array).
|
|
108
|
+
*/
|
|
109
|
+
export function toExtractableMessages(messages: MessageWithInfo[]): Message[] {
|
|
110
|
+
const result: Message[] = [];
|
|
111
|
+
for (const msg of messages) {
|
|
112
|
+
if (
|
|
113
|
+
typeof msg.role === 'string' &&
|
|
114
|
+
Array.isArray(msg.parts) &&
|
|
115
|
+
msg.parts.length > 0
|
|
116
|
+
) {
|
|
117
|
+
result.push({
|
|
118
|
+
role: msg.role,
|
|
119
|
+
parts: msg.parts as MessagePart[],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Extract the leading slash command from a user prompt.
|
|
128
|
+
* Returns the first whitespace-delimited token if it starts with '/'
|
|
129
|
+
* and contains at least one non-slash character after the leading slash.
|
|
130
|
+
*/
|
|
131
|
+
export function extractSlashCommand(prompt?: string): string | undefined {
|
|
132
|
+
if (!prompt) return undefined;
|
|
133
|
+
const first = prompt.trim().split(/\s+/, 1)[0];
|
|
134
|
+
// Must start with '/' and have at least one additional character
|
|
135
|
+
if (first.length > 1 && first.startsWith('/')) {
|
|
136
|
+
return first;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|