opencode-rules-md 0.6.5 → 0.6.6

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/src/cli/config.ts CHANGED
@@ -10,6 +10,19 @@ import * as os from 'node:os';
10
10
  import * as path from 'node:path';
11
11
  import type { CliFs } from './real-fs.js';
12
12
 
13
+ // ---------------------------------------------------------------------------
14
+ // Public constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** npm package name for this plugin. */
18
+ export const PLUGIN_NAME = 'opencode-rules-md';
19
+
20
+ /** Filename for the OpenCode server config. */
21
+ export const SERVER_CONFIG_FILENAME = 'opencode.json';
22
+
23
+ /** Filename for the OpenCode TUI config. */
24
+ export const TUI_CONFIG_FILENAME = 'tui.json';
25
+
13
26
  // ---------------------------------------------------------------------------
14
27
  // Public types
15
28
  // ---------------------------------------------------------------------------
@@ -22,77 +35,112 @@ export interface LoadResult {
22
35
  }
23
36
 
24
37
  // ---------------------------------------------------------------------------
25
- // JSONC comment stripper
38
+ // JSONC comment stripper — two-pass
26
39
  // ---------------------------------------------------------------------------
27
40
 
28
41
  /**
29
42
  * Strip JSONC comments and trailing commas while preserving
30
43
  * comment-like sequences inside string literals.
44
+ *
45
+ * Pass 1: strip `//` and `/* * /` comments (string-aware).
46
+ * Pass 2: strip trailing commas before `}` or `]`, skipping whitespace.
31
47
  */
32
48
  export function stripJsoncComments(content: string): string {
49
+ // Pass 1 — strip comments while preserving string contents
33
50
  let result = '';
34
51
  let i = 0;
35
- while (i < content.length) {
36
- const char = content[i];
52
+ let inString = false;
53
+ let escaped = false;
54
+ const len = content.length;
55
+
56
+ while (i < len) {
57
+ const char = content[i]!;
58
+
59
+ if (inString) {
60
+ result += char;
61
+ if (escaped) {
62
+ escaped = false;
63
+ } else if (char === '\\') {
64
+ escaped = true;
65
+ } else if (char === '"') {
66
+ inString = false;
67
+ }
68
+ i++;
69
+ continue;
70
+ }
37
71
 
38
- // Start of string (double-quoted only — JSON only supports double quotes)?
39
72
  if (char === '"') {
73
+ inString = true;
40
74
  result += char;
41
75
  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
76
  continue;
59
77
  }
60
78
 
61
- // Line comment?
79
+ // Line comment: // until newline
62
80
  if (char === '/' && content[i + 1] === '/') {
63
- while (i < content.length && content[i] !== '\n') {
64
- i++;
65
- }
81
+ i += 2;
82
+ while (i < len && content[i] !== '\n') i++;
66
83
  continue;
67
84
  }
68
85
 
69
- // Block comment?
86
+ // Block comment: /* until */
70
87
  if (char === '/' && content[i + 1] === '*') {
71
88
  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 */
89
+ while (i < len && !(content[i] === '*' && content[i + 1] === '/')) i++;
90
+ if (i < len) i += 2; // skip past */
91
+ continue;
92
+ }
93
+
94
+ result += char;
95
+ i++;
96
+ }
97
+
98
+ // Pass 2 — strip trailing commas (with whitespace skip) before } or ]
99
+ let out = '';
100
+ let inStr = false;
101
+ let esc = false;
102
+ let j = 0;
103
+ const outLen = result.length;
104
+
105
+ while (j < outLen) {
106
+ const ch = result[j]!;
107
+
108
+ if (inStr) {
109
+ out += ch;
110
+ if (esc) {
111
+ esc = false;
112
+ } else if (ch === '\\') {
113
+ esc = true;
114
+ } else if (ch === '"') {
115
+ inStr = false;
78
116
  }
117
+ j++;
79
118
  continue;
80
119
  }
81
120
 
82
- // Trailing comma before } or ]
83
- if (
84
- char === ',' &&
85
- (content[i + 1] === '}' || content[i + 1] === ']')
86
- ) {
87
- i++;
121
+ if (ch === '"') {
122
+ inStr = true;
123
+ out += ch;
124
+ j++;
88
125
  continue;
89
126
  }
90
127
 
91
- result += char;
92
- i++;
128
+ if (ch === ',') {
129
+ // Skip whitespace before checking for } or ]
130
+ let k = j + 1;
131
+ while (k < outLen && /\s/.test(result[k]!)) k++;
132
+ if (k < outLen && /[}\]]/.test(result[k]!)) {
133
+ // Drop the comma; jump to the bracket
134
+ j = k;
135
+ continue;
136
+ }
137
+ }
138
+
139
+ out += ch;
140
+ j++;
93
141
  }
94
142
 
95
- return result;
143
+ return out;
96
144
  }
97
145
 
98
146
  // ---------------------------------------------------------------------------
@@ -108,13 +156,14 @@ export function stripJsoncComments(content: string): string {
108
156
  */
109
157
  export function resolveGlobalConfigPath(
110
158
  fs: CliFs,
111
- opts: { ensureDir?: boolean } = {}
159
+ opts: { ensureDir?: boolean; filename?: string } = {}
112
160
  ): string {
113
161
  const dir = resolveConfigDir();
114
162
  if (opts.ensureDir && !fs.existsSync(dir)) {
115
163
  fs.mkdirSync(dir, { recursive: true });
116
164
  }
117
- return path.join(dir, 'opencode.json');
165
+ const filename = opts.filename ?? SERVER_CONFIG_FILENAME;
166
+ return path.join(dir, filename);
118
167
  }
119
168
 
120
169
  function resolveConfigDir(): string {
@@ -147,12 +196,15 @@ function resolveConfigDir(): string {
147
196
  // ---------------------------------------------------------------------------
148
197
 
149
198
  /**
150
- * Load and parse the global opencode config.
199
+ * Load and parse a global OpenCode config file.
151
200
  * Treats a missing file as an empty config object (not an error).
152
201
  * Surfaces parse errors so callers can exit non-zero without corrupting data.
153
202
  */
154
- export function loadGlobalConfig(fs: CliFs): LoadResult {
155
- const configPath = resolveGlobalConfigPath(fs);
203
+ export function loadGlobalConfig(fs: CliFs, opts: { filename?: string } = {}): LoadResult {
204
+ const configPath = resolveGlobalConfigPath(
205
+ fs,
206
+ opts.filename ? { filename: opts.filename } : {}
207
+ );
156
208
 
157
209
  if (!fs.existsSync(configPath)) {
158
210
  return { path: configPath, existed: false, config: {} };
@@ -240,19 +292,19 @@ export function addPlugin(
240
292
  /**
241
293
  * Write a timestamped backup of the given config path and rotate so at most
242
294
  * 3 backups are retained (oldest deleted first).
243
- * Returns the path of the newly created backup.
295
+ * Returns the path of the newly created backup, or null if no backup was created.
244
296
  */
245
- export function rotateBackups(fs: CliFs, configPath: string, _pluginName: string): string {
297
+ export function rotateBackups(fs: CliFs, configPath: string, _pluginName: string): string | null {
298
+ if (!fs.existsSync(configPath)) {
299
+ return null;
300
+ }
301
+
246
302
  const dir = path.dirname(configPath);
247
303
  const base = path.basename(configPath);
248
304
  const timestamp = Date.now();
249
305
  const newBak = path.join(dir, `${base}.bak.${timestamp}`);
250
306
 
251
- if (fs.existsSync(configPath)) {
252
- fs.copyFileSync(configPath, newBak);
253
- } else {
254
- fs.writeFileSync(newBak, '', 'utf-8');
255
- }
307
+ fs.copyFileSync(configPath, newBak);
256
308
 
257
309
  // Collect existing backups sorted by timestamp (lexical sort on numeric suffix)
258
310
  const entries = fs.readdirSync(dir);
@@ -280,7 +332,7 @@ export function rotateBackups(fs: CliFs, configPath: string, _pluginName: string
280
332
  export function writeAtomically(fs: CliFs, configPath: string, content: string): void {
281
333
  const dir = path.dirname(configPath);
282
334
  const base = path.basename(configPath);
283
- const tmpPath = path.join(dir, `.${base}.tmp.${Date.now()}`);
335
+ const tmpPath = path.join(dir, `.${base}.tmp.${Date.now()}-${process.pid}`);
284
336
 
285
337
  fs.writeFileSync(tmpPath, content, 'utf-8');
286
338
  fs.renameSync(tmpPath, configPath);
@@ -309,4 +361,4 @@ function collapseByPrefix(
309
361
  const keptIndices = new Set([...lastSeen.values()]);
310
362
 
311
363
  return plugins.filter((_, idx) => keptIndices.has(idx));
312
- }
364
+ }
@@ -1,13 +1,28 @@
1
1
  /**
2
- * install command: idempotently append opencode-rules-md to the global config.
2
+ * install command: idempotently append opencode-rules-md to the global configs.
3
3
  *
4
- * Pipeline:
5
- * loadGlobalConfig abort if parseError → dedupe → append specifier
4
+ * Registers the plugin in BOTH:
5
+ * - Server config: ~/.config/opencode/opencode.json
6
+ * - TUI config: ~/.config/opencode/tui.json
7
+ *
8
+ * Pipeline (per config):
9
+ * loadGlobalConfig → abort if parseError (throw) → normalizePlugin
6
10
  * → check no-op → (dry-run? print & return "planned")
7
11
  * → rotateBackups → writeAtomically → print & return "wrote"
12
+ *
13
+ * Errors are surfaced as exceptions instead of silent status returns so
14
+ * the CLI dispatcher can print them.
8
15
  */
9
16
 
10
- import { loadGlobalConfig, normalizePlugin, removePlugin, addPlugin, rotateBackups, writeAtomically } from './config.js';
17
+ import {
18
+ loadGlobalConfig,
19
+ normalizePlugin,
20
+ removePlugin,
21
+ addPlugin,
22
+ rotateBackups,
23
+ writeAtomically,
24
+ TUI_CONFIG_FILENAME,
25
+ } from './config.js';
11
26
  import type { CliFs } from './real-fs.js';
12
27
  import { realFs } from './real-fs.js';
13
28
 
@@ -15,12 +30,18 @@ import { realFs } from './real-fs.js';
15
30
  // Result types
16
31
  // ---------------------------------------------------------------------------
17
32
 
18
- export interface InstallResult {
19
- status: 'wrote' | 'planned' | 'noop' | 'error';
33
+ export interface ConfigUpdateResult {
34
+ status: 'wrote' | 'planned' | 'noop';
20
35
  path: string;
21
36
  specifier: string;
22
37
  backup?: string;
23
- parseError?: Error;
38
+ }
39
+
40
+ export interface InstallResult {
41
+ status: 'wrote' | 'planned' | 'noop';
42
+ specifier: string;
43
+ server: ConfigUpdateResult;
44
+ tui: ConfigUpdateResult;
24
45
  }
25
46
 
26
47
  // ---------------------------------------------------------------------------
@@ -31,17 +52,54 @@ export function runInstall(
31
52
  opts: { version?: string; dryRun?: boolean },
32
53
  fs: CliFs = realFs
33
54
  ): InstallResult {
34
- // Load config
35
- const loadResult = loadGlobalConfig(fs);
55
+ const specifier = buildSpecifier(opts.version);
56
+
57
+ const server = updateConfig(specifier, opts.dryRun ?? false, fs);
58
+ const tui = updateConfig(specifier, opts.dryRun ?? false, fs, {
59
+ filename: TUI_CONFIG_FILENAME,
60
+ });
61
+
62
+ const aggregate: InstallResult['status'] =
63
+ server.status === 'wrote' || tui.status === 'wrote'
64
+ ? 'wrote'
65
+ : server.status === 'planned' || tui.status === 'planned'
66
+ ? 'planned'
67
+ : 'noop';
68
+
69
+ printSummary(aggregate, specifier, server, tui);
36
70
 
37
- // Abort on parse error do not corrupt malformed config
71
+ return { status: aggregate, specifier, server, tui };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Internal: update a single config file
76
+ // ---------------------------------------------------------------------------
77
+
78
+ interface UpdateOpts {
79
+ filename?: string;
80
+ }
81
+
82
+ function updateConfig(
83
+ specifier: string,
84
+ dryRun: boolean,
85
+ fs: CliFs,
86
+ opts: UpdateOpts = {}
87
+ ): ConfigUpdateResult {
88
+ const loadResult = loadGlobalConfig(
89
+ fs,
90
+ opts.filename ? { filename: opts.filename } : {}
91
+ );
92
+
93
+ // Throw on parse error — better to fail fast than to silently corrupt config
38
94
  if (loadResult.parseError) {
39
- return {
40
- status: 'error',
41
- path: loadResult.path,
42
- specifier: buildSpecifier(opts.version),
43
- parseError: loadResult.parseError,
44
- };
95
+ const filename = opts.filename ?? 'opencode.json';
96
+ const err: Error & { configPath?: string } = new Error(
97
+ `opencode-rules-md: ${filename} is malformed — aborting to avoid data loss.\n` +
98
+ ` path: ${loadResult.path}\n` +
99
+ ` error: ${loadResult.parseError.message}`
100
+ );
101
+ err.configPath = loadResult.path;
102
+ throw err;
45
103
  }
46
104
 
47
105
  const configPath = loadResult.path;
@@ -50,9 +108,6 @@ export function runInstall(
50
108
  // Normalize plugin array (dedupe by prefix)
51
109
  config['plugin'] = normalizePlugin(config);
52
110
 
53
- // Build the specifier
54
- const specifier = buildSpecifier(opts.version);
55
-
56
111
  // Check if already installed with the same specifier — no-op
57
112
  const existing = (config['plugin'] as string[] | undefined) ?? [];
58
113
  if (existing.includes(specifier)) {
@@ -66,9 +121,9 @@ export function runInstall(
66
121
  addPlugin(config, specifier);
67
122
 
68
123
  // Dry-run: serialize and print planned content, make no changes
69
- if (opts.dryRun) {
124
+ if (dryRun) {
70
125
  const planned = JSON.stringify(config, null, 2);
71
- console.log('Planned config:\n' + planned);
126
+ console.log(`Planned ${configPath}:\n${planned}`);
72
127
  return { status: 'planned', path: configPath, specifier };
73
128
  }
74
129
 
@@ -77,12 +132,9 @@ export function runInstall(
77
132
  const serialized = JSON.stringify(config, null, 2);
78
133
  writeAtomically(fs, configPath, serialized);
79
134
 
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 };
135
+ const result: ConfigUpdateResult = { status: 'wrote', path: configPath, specifier };
136
+ if (backup) result.backup = backup;
137
+ return result;
86
138
  }
87
139
 
88
140
  // ---------------------------------------------------------------------------
@@ -92,3 +144,28 @@ export function runInstall(
92
144
  function buildSpecifier(version?: string): string {
93
145
  return version ? `opencode-rules-md@${version}` : 'opencode-rules-md';
94
146
  }
147
+
148
+ function printSummary(
149
+ status: InstallResult['status'],
150
+ specifier: string,
151
+ server: ConfigUpdateResult,
152
+ tui: ConfigUpdateResult
153
+ ): void {
154
+ if (status === 'noop') {
155
+ console.log(`Already installed (${specifier})`);
156
+ console.log(` server config: ${server.path}`);
157
+ console.log(` tui config: ${tui.path}`);
158
+ return;
159
+ }
160
+
161
+ if (status === 'planned') {
162
+ console.log('Dry run complete — no files written.');
163
+ return;
164
+ }
165
+
166
+ console.log(`Installed ${specifier}`);
167
+ console.log(` server config: ${server.path}`);
168
+ if (server.backup) console.log(` backup: ${server.backup}`);
169
+ console.log(` tui config: ${tui.path}`);
170
+ if (tui.backup) console.log(` backup: ${tui.backup}`);
171
+ }
package/src/cli/main.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * CLI entrypoint for opencode-rules-md installer.
4
4
  *
5
5
  * Supports:
6
- * install — add opencode-rules-md to the global opencode config
6
+ * install — add opencode-rules-md to the global opencode + tui configs
7
7
  * status — report whether opencode-rules-md is installed
8
8
  *
9
9
  * Flags:
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import { parseArgs } from 'node:util';
18
+ import { pathToFileURL } from 'node:url';
19
+ import { realpathSync } from 'node:fs';
18
20
  import { runInstall } from './install.js';
19
21
  import { runStatus } from './status.js';
20
22
  import type { CliFs } from './real-fs.js';
@@ -29,7 +31,7 @@ const HELP_TEXT = `opencode-rules-md CLI
29
31
  Usage: opencode-rules-md <command> [options]
30
32
 
31
33
  Commands:
32
- install Add opencode-rules-md to the global opencode config
34
+ install Add opencode-rules-md to the global opencode + tui configs
33
35
  status Report whether opencode-rules-md is installed
34
36
 
35
37
  Options:
@@ -40,8 +42,7 @@ Options:
40
42
  -h, --help Show this help text
41
43
  `.trim();
42
44
 
43
- const USAGE_ERROR_TEXT = `Error: unknown command. Run 'opencode-rules-md --help' for usage.
44
- `.trim();
45
+ const USAGE_ERROR_TEXT = `Error: unknown command. Run 'opencode-rules-md --help' for usage.`.trim();
45
46
 
46
47
  // ---------------------------------------------------------------------------
47
48
  // Argument parsing
@@ -82,9 +83,6 @@ function parseCliArgs(argv: string[]): { command: string | null; options: CliOpt
82
83
  ...((values.help || values.h) && { help: true }),
83
84
  };
84
85
 
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
86
  const unknownFlags: string[] = [];
89
87
 
90
88
  return { command, options, unknownFlags };
@@ -124,26 +122,19 @@ export async function runMain(argv: string[], fs: CliFs = realFs): Promise<ExitC
124
122
  const installOpts: { version?: string; dryRun?: boolean } = {};
125
123
  if (options.version !== undefined) installOpts.version = options.version;
126
124
  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
- }
125
+ runInstall(installOpts, fs);
132
126
  return 0;
133
127
  }
134
-
135
128
  case 'status': {
136
129
  const statusResult = runStatus(fs);
137
- // Print status to stdout
138
130
  if (statusResult.installed) {
139
- console.log(`opencode-rules-md is installed (${statusResult.specifier})`);
131
+ const spec = statusResult.serverSpecifier ?? statusResult.tuiSpecifier;
132
+ console.log(`opencode-rules-md is installed (${spec})`);
140
133
  } else {
141
134
  console.log('opencode-rules-md is not installed');
142
135
  }
143
- // Exit code 0 regardless of install state (status is read-only)
144
136
  return 0;
145
137
  }
146
-
147
138
  default: {
148
139
  console.error(`Error: unknown command '${command}'`);
149
140
  console.error(USAGE_ERROR_TEXT);
@@ -151,7 +142,8 @@ export async function runMain(argv: string[], fs: CliFs = realFs): Promise<ExitC
151
142
  }
152
143
  }
153
144
  } catch (err) {
154
- console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
145
+ const message = err instanceof Error ? err.message : String(err);
146
+ console.error(`Error: ${message}`);
155
147
  return 1;
156
148
  }
157
149
  }
@@ -160,8 +152,27 @@ export async function runMain(argv: string[], fs: CliFs = realFs): Promise<ExitC
160
152
  // Entry point
161
153
  // ---------------------------------------------------------------------------
162
154
 
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);
155
+ /**
156
+ * Determine whether the current module is being executed as the main entry.
157
+ * Uses realpathSync + pathToFileURL so symlinked invocations (e.g. npx)
158
+ * are matched correctly.
159
+ */
160
+ function isInvokedAsMain(): boolean {
161
+ if (!process.argv[1]) return false;
162
+
163
+ try {
164
+ const realArgv = pathToFileURL(realpathSync(process.argv[1])).href;
165
+ return import.meta.url === realArgv;
166
+ } catch {
167
+ try {
168
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
167
173
  }
174
+
175
+ // Only run if executed directly (not imported as a module)
176
+ if (isInvokedAsMain()) {
177
+ void runMain(process.argv.slice(2)).then(code => process.exit(code));
178
+ }
package/src/cli/status.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * status command: read-only probe that reports install state.
3
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
4
+ * Reports install state across BOTH the server and TUI configs:
5
+ * - installed: yes/no (true only when present in both configs)
6
+ * - serverSpecifier: the registered entry in opencode.json (or undefined)
7
+ * - tuiSpecifier: the registered entry in tui.json (or undefined)
8
+ * - serverPath: path to the server config
9
+ * - tuiPath: path to the TUI config
10
+ * - version: bundled CLI version from package.json
9
11
  */
10
12
 
11
- import { loadGlobalConfig } from './config.js';
13
+ import { loadGlobalConfig, TUI_CONFIG_FILENAME } from './config.js';
12
14
  import type { CliFs } from './real-fs.js';
13
15
  import { realFs } from './real-fs.js';
14
16
  import * as fs from 'node:fs';
@@ -19,10 +21,23 @@ import * as path from 'node:path';
19
21
  // ---------------------------------------------------------------------------
20
22
 
21
23
  export interface StatusResult {
24
+ /** True only when the plugin is registered in both server and TUI configs. */
22
25
  installed: boolean;
23
- path: string;
24
- specifier?: string;
26
+ /** Server config path. */
27
+ serverPath: string;
28
+ /** TUI config path. */
29
+ tuiPath: string;
30
+ /** Registered specifier in the server config, if any. */
31
+ serverSpecifier?: string;
32
+ /** Registered specifier in the TUI config, if any. */
33
+ tuiSpecifier?: string;
34
+ /** Whether the server config existed on disk before the probe. */
35
+ serverExisted: boolean;
36
+ /** Whether the TUI config existed on disk before the probe. */
37
+ tuiExisted: boolean;
38
+ /** Bundled CLI version from package.json. */
25
39
  version: string;
40
+ /** First parse error encountered, if any (server takes priority). */
26
41
  parseError?: Error;
27
42
  }
28
43
 
@@ -30,35 +45,30 @@ export interface StatusResult {
30
45
  // Run status
31
46
  // ---------------------------------------------------------------------------
32
47
 
33
- export function runStatus(fs: CliFs = realFs): StatusResult {
34
- const loadResult = loadGlobalConfig(fs);
35
- const configPath = loadResult.path;
48
+ export function runStatus(cliFs: CliFs = realFs): StatusResult {
49
+ const serverLoad = loadGlobalConfig(cliFs);
50
+ const tuiLoad = loadGlobalConfig(cliFs, { filename: TUI_CONFIG_FILENAME });
36
51
 
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[]) : [];
52
+ const serverSpecifier = findSpecifier(serverLoad);
53
+ const tuiSpecifier = findSpecifier(tuiLoad);
49
54
 
50
- // Find the opencode-rules-md entry (exact match for idempotency)
51
- const specifier = pluginList.find(p => p.startsWith('opencode-rules-md'));
55
+ const installed = serverSpecifier !== undefined && tuiSpecifier !== undefined;
52
56
 
53
57
  const result: StatusResult = {
54
- installed: specifier !== undefined,
55
- path: configPath,
58
+ installed,
59
+ serverPath: serverLoad.path,
60
+ tuiPath: tuiLoad.path,
61
+ serverExisted: serverLoad.existed,
62
+ tuiExisted: tuiLoad.existed,
56
63
  version: getVersion(),
57
64
  };
58
65
 
59
- if (specifier) {
60
- result.specifier = specifier;
61
- }
66
+ if (serverSpecifier) result.serverSpecifier = serverSpecifier;
67
+ if (tuiSpecifier) result.tuiSpecifier = tuiSpecifier;
68
+
69
+ // Surface the first parse error so callers can warn the user
70
+ const parseError = serverLoad.parseError ?? tuiLoad.parseError;
71
+ if (parseError) result.parseError = parseError;
62
72
 
63
73
  return result;
64
74
  }
@@ -67,6 +77,15 @@ export function runStatus(fs: CliFs = realFs): StatusResult {
67
77
  // Internal helpers
68
78
  // ---------------------------------------------------------------------------
69
79
 
80
+ function findSpecifier(load: ReturnType<typeof loadGlobalConfig>): string | undefined {
81
+ if (load.parseError) return undefined;
82
+ const config = load.config;
83
+ const pluginList: string[] = Array.isArray(config['plugin'])
84
+ ? (config['plugin'] as string[])
85
+ : [];
86
+ return pluginList.find(p => p.startsWith('opencode-rules-md'));
87
+ }
88
+
70
89
  function getVersion(): string {
71
90
  try {
72
91
  // Read package.json relative to the project root
@@ -77,4 +96,4 @@ function getVersion(): string {
77
96
  } catch {
78
97
  return 'unknown';
79
98
  }
80
- }
99
+ }