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,166 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import * as crypto from 'node:crypto';
5
+ import { createDebugLog } from './debug';
6
+
7
+ const debugLog = createDebugLog();
8
+
9
+ export interface ActiveRulesState {
10
+ sessionId: string;
11
+ matchedRulePaths: string[];
12
+ evaluatedAt: number;
13
+ }
14
+
15
+ // Per-session write queue to serialize concurrent writes
16
+ const writeQueues = new Map<string, Promise<void>>();
17
+
18
+ // Allows tests to override the state directory
19
+ let stateDirOverride: string | null = null;
20
+
21
+ // Strict pattern for safe sessionId: alphanumeric, underscore, hyphen only
22
+ const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
23
+
24
+ function isValidSessionId(sessionId: string): boolean {
25
+ return SAFE_SESSION_ID_PATTERN.test(sessionId);
26
+ }
27
+
28
+ /** @internal Test-only: override the state directory */
29
+ export function _setStateDirForTesting(dir: string | null): void {
30
+ stateDirOverride = dir;
31
+ }
32
+
33
+ export function resolveStateDir(): string {
34
+ if (stateDirOverride !== null) {
35
+ return stateDirOverride;
36
+ }
37
+ return path.join(os.homedir(), '.opencode', 'state', 'opencode-rules-md');
38
+ }
39
+
40
+ export function getStateFilePath(sessionId: string): string {
41
+ if (!isValidSessionId(sessionId)) {
42
+ throw new Error(`Invalid sessionId: ${sessionId}`);
43
+ }
44
+ return path.join(resolveStateDir(), `${sessionId}.json`);
45
+ }
46
+
47
+ export function writeActiveRulesState(
48
+ sessionId: string,
49
+ matchedPaths: string[]
50
+ ): void {
51
+ if (!isValidSessionId(sessionId)) {
52
+ debugLog(`Invalid sessionId rejected: ${sessionId}`);
53
+ return;
54
+ }
55
+
56
+ const state: ActiveRulesState = {
57
+ sessionId,
58
+ matchedRulePaths: matchedPaths,
59
+ evaluatedAt: Date.now(),
60
+ };
61
+
62
+ // Chain onto existing queue for this session, or start fresh
63
+ const previousWrite = writeQueues.get(sessionId) ?? Promise.resolve();
64
+
65
+ const currentWrite = previousWrite.then(async () => {
66
+ await doAtomicWrite(sessionId, state);
67
+ });
68
+
69
+ writeQueues.set(sessionId, currentWrite);
70
+
71
+ // Fire-and-forget: catch errors to prevent unhandled rejection
72
+ currentWrite.catch(() => {
73
+ // Errors already logged in doAtomicWrite
74
+ });
75
+ }
76
+
77
+ async function doAtomicWrite(
78
+ sessionId: string,
79
+ state: ActiveRulesState
80
+ ): Promise<void> {
81
+ const stateDir = resolveStateDir();
82
+ const finalPath = getStateFilePath(sessionId);
83
+ const tempPath = path.join(
84
+ stateDir,
85
+ `.${sessionId}-${crypto.randomBytes(8).toString('hex')}.tmp`
86
+ );
87
+
88
+ try {
89
+ // Ensure directory exists
90
+ await fs.mkdir(stateDir, { recursive: true });
91
+
92
+ // Write to temp file
93
+ const content = JSON.stringify(state);
94
+ await fs.writeFile(tempPath, content, 'utf-8');
95
+
96
+ // Atomic rename
97
+ await fs.rename(tempPath, finalPath);
98
+ } catch (error) {
99
+ debugLog(
100
+ `Failed to write active rules state for session ${sessionId}: ${error}`
101
+ );
102
+
103
+ // Clean up temp file if it exists
104
+ try {
105
+ await fs.unlink(tempPath);
106
+ } catch {
107
+ // Ignore cleanup errors
108
+ }
109
+ }
110
+ }
111
+
112
+ export async function readActiveRulesState(
113
+ sessionId: string
114
+ ): Promise<ActiveRulesState | null> {
115
+ if (!isValidSessionId(sessionId)) {
116
+ debugLog(`Invalid sessionId rejected: ${sessionId}`);
117
+ return null;
118
+ }
119
+
120
+ const filePath = getStateFilePath(sessionId);
121
+
122
+ try {
123
+ const content = await fs.readFile(filePath, 'utf-8');
124
+ const parsed: unknown = JSON.parse(content);
125
+
126
+ if (!isValidActiveRulesState(parsed)) {
127
+ debugLog(`Invalid active rules state format for session ${sessionId}`);
128
+ return null;
129
+ }
130
+
131
+ return parsed;
132
+ } catch (error) {
133
+ debugLog(
134
+ `Failed to read active rules state for session ${sessionId}: ${error}`
135
+ );
136
+ return null;
137
+ }
138
+ }
139
+
140
+ function isValidActiveRulesState(value: unknown): value is ActiveRulesState {
141
+ if (typeof value !== 'object' || value === null) {
142
+ return false;
143
+ }
144
+
145
+ const obj = value as Record<string, unknown>;
146
+
147
+ if (typeof obj['sessionId'] !== 'string') {
148
+ return false;
149
+ }
150
+
151
+ if (typeof obj['evaluatedAt'] !== 'number') {
152
+ return false;
153
+ }
154
+
155
+ if (!Array.isArray(obj['matchedRulePaths'])) {
156
+ return false;
157
+ }
158
+
159
+ for (const item of obj['matchedRulePaths']) {
160
+ if (typeof item !== 'string') {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ return true;
166
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * CLI config helpers: discovery, JSONC-safe parsing, plugin normalization,
3
+ * backup rotation, and atomic write.
4
+ *
5
+ * All filesystem access goes through the CliFs interface so tests can inject
6
+ * an in-memory implementation without touching real disk.
7
+ */
8
+
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import type { CliFs } from './real-fs.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Public types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface LoadResult {
18
+ path: string;
19
+ existed: boolean;
20
+ config: Record<string, unknown>;
21
+ parseError?: Error;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // JSONC comment stripper
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Strip JSONC comments and trailing commas while preserving
30
+ * comment-like sequences inside string literals.
31
+ */
32
+ export function stripJsoncComments(content: string): string {
33
+ let result = '';
34
+ let i = 0;
35
+ while (i < content.length) {
36
+ const char = content[i];
37
+
38
+ // Start of string (double-quoted only — JSON only supports double quotes)?
39
+ if (char === '"') {
40
+ result += char;
41
+ i++;
42
+ while (i < content.length) {
43
+ const c = content[i];
44
+ if (c === '\\' && i + 1 < content.length) {
45
+ // Escape sequence — copy both chars verbatim
46
+ result += c + content[i + 1];
47
+ i += 2;
48
+ continue;
49
+ }
50
+ if (c === '"') {
51
+ result += c;
52
+ i++;
53
+ break;
54
+ }
55
+ result += c;
56
+ i++;
57
+ }
58
+ continue;
59
+ }
60
+
61
+ // Line comment?
62
+ if (char === '/' && content[i + 1] === '/') {
63
+ while (i < content.length && content[i] !== '\n') {
64
+ i++;
65
+ }
66
+ continue;
67
+ }
68
+
69
+ // Block comment?
70
+ if (char === '/' && content[i + 1] === '*') {
71
+ i += 2;
72
+ while (i + 1 < content.length && !(content[i] === '*' && content[i + 1] === '/')) {
73
+ i++;
74
+ }
75
+ // Only skip past */ if we actually found it
76
+ if (i + 1 < content.length) {
77
+ i += 2; // skip */
78
+ }
79
+ continue;
80
+ }
81
+
82
+ // Trailing comma before } or ]
83
+ if (
84
+ char === ',' &&
85
+ (content[i + 1] === '}' || content[i + 1] === ']')
86
+ ) {
87
+ i++;
88
+ continue;
89
+ }
90
+
91
+ result += char;
92
+ i++;
93
+ }
94
+
95
+ return result;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Config path resolution
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Resolve the global opencode config path using the same precedence as
104
+ * opencode itself: OPENCODE_CONFIG_DIR → XDG_CONFIG_HOME → HOME fallback.
105
+ *
106
+ * On Windows, APPDATA is tried as a last resort when XDG_CONFIG_HOME and
107
+ * HOME are both absent.
108
+ */
109
+ export function resolveGlobalConfigPath(
110
+ fs: CliFs,
111
+ opts: { ensureDir?: boolean } = {}
112
+ ): string {
113
+ const dir = resolveConfigDir();
114
+ if (opts.ensureDir && !fs.existsSync(dir)) {
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ }
117
+ return path.join(dir, 'opencode.json');
118
+ }
119
+
120
+ function resolveConfigDir(): string {
121
+ if (process.env.OPENCODE_CONFIG_DIR) {
122
+ return process.env.OPENCODE_CONFIG_DIR;
123
+ }
124
+
125
+ if (process.env.XDG_CONFIG_HOME) {
126
+ return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
127
+ }
128
+
129
+ const home = process.env.HOME || os.homedir();
130
+ if (home) {
131
+ return path.join(home, '.config', 'opencode');
132
+ }
133
+
134
+ // Windows fallback
135
+ const appdata = process.env.APPDATA;
136
+ if (appdata) {
137
+ return path.join(appdata, 'opencode');
138
+ }
139
+
140
+ throw new Error(
141
+ 'Cannot resolve config dir: none of OPENCODE_CONFIG_DIR, XDG_CONFIG_HOME, HOME, or APPDATA is set'
142
+ );
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // JSONC-safe config load
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Load and parse the global opencode config.
151
+ * Treats a missing file as an empty config object (not an error).
152
+ * Surfaces parse errors so callers can exit non-zero without corrupting data.
153
+ */
154
+ export function loadGlobalConfig(fs: CliFs): LoadResult {
155
+ const configPath = resolveGlobalConfigPath(fs);
156
+
157
+ if (!fs.existsSync(configPath)) {
158
+ return { path: configPath, existed: false, config: {} };
159
+ }
160
+
161
+ try {
162
+ const raw = fs.readFileSync(configPath);
163
+ const stripped = stripJsoncComments(raw);
164
+ // Empty after stripping means comment-only file — treat as {}
165
+ if (stripped.trim() === '') {
166
+ return { path: configPath, existed: true, config: {} };
167
+ }
168
+ const parsed = JSON.parse(stripped) as Record<string, unknown>;
169
+ return { path: configPath, existed: true, config: parsed };
170
+ } catch (err) {
171
+ const error = err instanceof Error ? err : new Error(String(err));
172
+ return { path: configPath, existed: true, config: {}, parseError: error };
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Plugin normalization helpers
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Return the plugin array with opencode-rules-md* entries deduplicated by
182
+ * name prefix, keeping the last occurrence of each unique prefix.
183
+ */
184
+ export function normalizePlugin(
185
+ config: Record<string, unknown>
186
+ ): string[] {
187
+ const raw: unknown[] = Array.isArray(config['plugin']) ? config['plugin'] : [];
188
+ const seen = new Map<string, number>(); // prefix → last index
189
+ const result: string[] = [];
190
+
191
+ for (const item of raw) {
192
+ if (typeof item !== 'string') continue;
193
+ const prefix = getPluginPrefix(item);
194
+ seen.set(prefix, result.length);
195
+ result.push(item);
196
+ }
197
+
198
+ // Collapse entries sharing a prefix to only the last one
199
+ return collapseByPrefix(result, seen);
200
+ }
201
+
202
+ /**
203
+ * Remove all plugins matching the given prefix (used to dedupe before append).
204
+ */
205
+ export function removePlugin(
206
+ config: Record<string, unknown>,
207
+ prefix: string
208
+ ): void {
209
+ if (!Array.isArray(config['plugin'])) return;
210
+ const raw = config['plugin'] as string[];
211
+ config['plugin'] = raw.filter(
212
+ item => typeof item === 'string' && !item.startsWith(prefix)
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Add a plugin specifier, removing any existing entry with the same prefix first
218
+ * so it ends up at the specified position (default: end). This makes the operation
219
+ * idempotent — re-adding the same specifier just moves it to the end.
220
+ */
221
+ export function addPlugin(
222
+ config: Record<string, unknown>,
223
+ specifier: string
224
+ ): void {
225
+ if (!Array.isArray(config['plugin'])) {
226
+ config['plugin'] = [];
227
+ }
228
+ const plugin = config['plugin'] as string[];
229
+ const prefix = getPluginPrefix(specifier);
230
+ // Remove any existing entry with the same prefix
231
+ const removed = plugin.filter(p => getPluginPrefix(p) !== prefix);
232
+ removed.push(specifier);
233
+ config['plugin'] = removed;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Backup rotation
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Write a timestamped backup of the given config path and rotate so at most
242
+ * 3 backups are retained (oldest deleted first).
243
+ * Returns the path of the newly created backup.
244
+ */
245
+ export function rotateBackups(fs: CliFs, configPath: string, _pluginName: string): string {
246
+ const dir = path.dirname(configPath);
247
+ const base = path.basename(configPath);
248
+ const timestamp = Date.now();
249
+ const newBak = path.join(dir, `${base}.bak.${timestamp}`);
250
+
251
+ if (fs.existsSync(configPath)) {
252
+ fs.copyFileSync(configPath, newBak);
253
+ } else {
254
+ fs.writeFileSync(newBak, '', 'utf-8');
255
+ }
256
+
257
+ // Collect existing backups sorted by timestamp (lexical sort on numeric suffix)
258
+ const entries = fs.readdirSync(dir);
259
+ const bakFiles = entries
260
+ .filter(e => e.startsWith(base + '.bak.'))
261
+ .sort();
262
+
263
+ // Remove oldest until only 3 remain
264
+ while (bakFiles.length > 3) {
265
+ const oldest = bakFiles.shift()!;
266
+ fs.unlinkSync(path.join(dir, oldest));
267
+ }
268
+
269
+ return newBak;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Atomic write
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Write content to a temporary sibling file then rename it into place.
278
+ * The final path is never in a partially-written state.
279
+ */
280
+ export function writeAtomically(fs: CliFs, configPath: string, content: string): void {
281
+ const dir = path.dirname(configPath);
282
+ const base = path.basename(configPath);
283
+ const tmpPath = path.join(dir, `.${base}.tmp.${Date.now()}`);
284
+
285
+ fs.writeFileSync(tmpPath, content, 'utf-8');
286
+ fs.renameSync(tmpPath, configPath);
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Internal helpers
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Extract the name part of a plugin specifier, stripping any version suffix.
295
+ * e.g. "opencode-rules-md@0.1.0" → "opencode-rules-md"
296
+ */
297
+ function getPluginPrefix(specifier: string): string {
298
+ const atIdx = specifier.indexOf('@');
299
+ return atIdx === -1 ? specifier : specifier.substring(0, atIdx);
300
+ }
301
+
302
+ /**
303
+ * Collapse plugins by prefix, keeping only the last occurrence of each.
304
+ */
305
+ function collapseByPrefix(
306
+ plugins: string[],
307
+ lastSeen: Map<string, number>
308
+ ): string[] {
309
+ const keptIndices = new Set([...lastSeen.values()]);
310
+
311
+ return plugins.filter((_, idx) => keptIndices.has(idx));
312
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * install command: idempotently append opencode-rules-md to the global config.
3
+ *
4
+ * Pipeline:
5
+ * loadGlobalConfig → abort if parseError → dedupe → append specifier
6
+ * → check no-op → (dry-run? print & return "planned")
7
+ * → rotateBackups → writeAtomically → print & return "wrote"
8
+ */
9
+
10
+ import { loadGlobalConfig, normalizePlugin, removePlugin, addPlugin, rotateBackups, writeAtomically } from './config.js';
11
+ import type { CliFs } from './real-fs.js';
12
+ import { realFs } from './real-fs.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Result types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface InstallResult {
19
+ status: 'wrote' | 'planned' | 'noop' | 'error';
20
+ path: string;
21
+ specifier: string;
22
+ backup?: string;
23
+ parseError?: Error;
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Run install
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export function runInstall(
31
+ opts: { version?: string; dryRun?: boolean },
32
+ fs: CliFs = realFs
33
+ ): InstallResult {
34
+ // Load config
35
+ const loadResult = loadGlobalConfig(fs);
36
+
37
+ // Abort on parse error — do not corrupt malformed config
38
+ if (loadResult.parseError) {
39
+ return {
40
+ status: 'error',
41
+ path: loadResult.path,
42
+ specifier: buildSpecifier(opts.version),
43
+ parseError: loadResult.parseError,
44
+ };
45
+ }
46
+
47
+ const configPath = loadResult.path;
48
+ const config = loadResult.config;
49
+
50
+ // Normalize plugin array (dedupe by prefix)
51
+ config['plugin'] = normalizePlugin(config);
52
+
53
+ // Build the specifier
54
+ const specifier = buildSpecifier(opts.version);
55
+
56
+ // Check if already installed with the same specifier — no-op
57
+ const existing = (config['plugin'] as string[] | undefined) ?? [];
58
+ if (existing.includes(specifier)) {
59
+ return { status: 'noop', path: configPath, specifier };
60
+ }
61
+
62
+ // Remove any existing opencode-rules-md* entries so this specifier is deduplicated
63
+ removePlugin(config, 'opencode-rules-md');
64
+
65
+ // Add the new (or re-added) specifier at the end
66
+ addPlugin(config, specifier);
67
+
68
+ // Dry-run: serialize and print planned content, make no changes
69
+ if (opts.dryRun) {
70
+ const planned = JSON.stringify(config, null, 2);
71
+ console.log('Planned config:\n' + planned);
72
+ return { status: 'planned', path: configPath, specifier };
73
+ }
74
+
75
+ // Write: rotate backups first, then atomic write
76
+ const backup = rotateBackups(fs, configPath, 'opencode-rules-md');
77
+ const serialized = JSON.stringify(config, null, 2);
78
+ writeAtomically(fs, configPath, serialized);
79
+
80
+ console.log(`Installed ${specifier} to ${configPath}`);
81
+ if (backup) {
82
+ console.log(`Backup written to ${backup}`);
83
+ }
84
+
85
+ return { status: 'wrote', path: configPath, specifier, backup };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Internal helpers
90
+ // ---------------------------------------------------------------------------
91
+
92
+ function buildSpecifier(version?: string): string {
93
+ return version ? `opencode-rules-md@${version}` : 'opencode-rules-md';
94
+ }