opencode-rules-md 0.8.3 → 0.8.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 (42) hide show
  1. package/dist/cli.mjs +915 -0
  2. package/dist/src/cli/config.d.ts +93 -0
  3. package/dist/src/cli/config.d.ts.map +1 -0
  4. package/dist/src/cli/config.js +382 -0
  5. package/dist/src/cli/config.js.map +1 -0
  6. package/dist/src/cli/install.d.ts +35 -0
  7. package/dist/src/cli/install.d.ts.map +1 -0
  8. package/dist/src/cli/install.js +148 -0
  9. package/dist/src/cli/install.js.map +1 -0
  10. package/dist/src/cli/main.d.ts +14 -0
  11. package/dist/src/cli/main.d.ts.map +1 -0
  12. package/dist/src/cli/main.js +244 -0
  13. package/dist/src/cli/main.js.map +1 -0
  14. package/dist/src/cli/real-fs.d.ts +3 -0
  15. package/dist/src/cli/real-fs.d.ts.map +1 -0
  16. package/dist/src/cli/real-fs.js +32 -0
  17. package/dist/src/cli/real-fs.js.map +1 -0
  18. package/dist/src/cli/registry.d.ts +12 -0
  19. package/dist/src/cli/registry.d.ts.map +1 -0
  20. package/dist/src/cli/registry.js +34 -0
  21. package/dist/src/cli/registry.js.map +1 -0
  22. package/dist/src/cli/status.d.ts +44 -0
  23. package/dist/src/cli/status.d.ts.map +1 -0
  24. package/dist/src/cli/status.js +182 -0
  25. package/dist/src/cli/status.js.map +1 -0
  26. package/dist/src/cli/uninstall.d.ts +27 -0
  27. package/dist/src/cli/uninstall.d.ts.map +1 -0
  28. package/dist/src/cli/uninstall.js +101 -0
  29. package/dist/src/cli/uninstall.js.map +1 -0
  30. package/dist/src/cli/update.d.ts +31 -0
  31. package/dist/src/cli/update.d.ts.map +1 -0
  32. package/dist/src/cli/update.js +173 -0
  33. package/dist/src/cli/update.js.map +1 -0
  34. package/package.json +11 -15
  35. package/src/cli/config.ts +461 -0
  36. package/src/cli/install.ts +211 -0
  37. package/src/cli/main.ts +299 -0
  38. package/src/cli/real-fs.ts +44 -0
  39. package/src/cli/registry.ts +36 -0
  40. package/src/cli/status.ts +247 -0
  41. package/src/cli/uninstall.ts +146 -0
  42. package/src/cli/update.ts +220 -0
@@ -0,0 +1,299 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/main.ts — `omd` CLI entry point.
3
+ //
4
+ // Command dispatch using node:util.parseArgs.
5
+ // Bare `omd` → install (default).
6
+ // Exit codes: 0 success/no-op, 1 health failure, 2 usage error.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { parseArgs } from 'node:util';
10
+ import { runInstall, type InstallOptions } from './install.js';
11
+ import { runUninstall, type UninstallOptions } from './uninstall.js';
12
+ import { runStatus, runDoctor } from './status.js';
13
+ import { runUpdate } from './update.js';
14
+ import { createRealFs } from './real-fs.js';
15
+ import type { CliFs } from './config.js';
16
+
17
+ // ─── Usage ───────────────────────────────────────────────────────────────────
18
+
19
+ const USAGE = `omd — opencode-rules-md plugin manager
20
+
21
+ Usage:
22
+ omd [command] [options]
23
+
24
+ Commands:
25
+ install Register opencode-rules-md in both opencode.json and tui.json
26
+ uninstall Remove opencode-rules-md from both configs
27
+ status Show installed plugin state for each config
28
+ doctor Run health checks for the plugin environment
29
+ update Check for new versions and purge stale cache
30
+
31
+ Options:
32
+ --dry-run Show what would be changed without writing
33
+ --version Pin to a specific version (install only)
34
+ --latest Use the latest version (install only)
35
+ --purge Also remove ~/.cache/opencode/node_modules/opencode-rules-md (uninstall only)
36
+ --yes Accept all prompts automatically
37
+
38
+ Examples:
39
+ omd # install with defaults (latest)
40
+ omd install # same as bare omd
41
+ omd install --dry-run
42
+ omd install --version 2.0.0
43
+ omd uninstall --purge
44
+ `.trim();
45
+
46
+ const printUsage = (stdout: (s: string) => void): void => {
47
+ stdout(USAGE);
48
+ };
49
+
50
+ // ─── Manual argv parsing — extract command before passing to parseArgs ────────
51
+
52
+ /**
53
+ * Known option flags (long-form).
54
+ * We detect unknown flags by scanning argv for anything starting with `-` or `--`
55
+ * that is NOT in this set.
56
+ */
57
+ const KNOWN_FLAGS = new Set([
58
+ '--help', '--dry-run', '--latest', '--purge', '--yes', '--version',
59
+ '-h', '-V',
60
+ ]);
61
+
62
+ /**
63
+ * Known short options that take a value.
64
+ * e.g. `--version 2.0.0` → values.version = '2.0.0'
65
+ */
66
+ const SHORT_OPTIONS_WITH_VALUE = new Set(['v']);
67
+
68
+ /**
69
+ * Known long options that take a value.
70
+ */
71
+ const LONG_OPTIONS_WITH_VALUE = new Set(['version']);
72
+
73
+ /**
74
+ * Extract the command name (first non-option positional) from argv.
75
+ * Also returns unknown flags found before the command.
76
+ *
77
+ * Examples:
78
+ * [] → { command: 'install', args: [], unknownFlags: [] }
79
+ * ['install'] → { command: 'install', args: [], unknownFlags: [] }
80
+ * ['notacommand'] → { command: 'notacommand', args: [], unknownFlags: [] }
81
+ * ['install', '--dry-run'] → { command: 'install', args: ['--dry-run'], unknownFlags: [] }
82
+ * ['install', '--unknown-opt'] → { command: 'install', args: ['--unknown-opt'], unknownFlags: ['--unknown-opt'] }
83
+ */
84
+ function extractCommand(
85
+ argv: string[],
86
+ ): { command: string | null; unknownFlags: string[] } {
87
+ const unknownFlags: string[] = [];
88
+ let command: string | null = null;
89
+
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const arg = argv[i]!;
92
+
93
+ if (arg === '--') {
94
+ // Argument separator — remaining args are positional
95
+ break;
96
+ }
97
+
98
+ if (!arg.startsWith('-')) {
99
+ // First non-option is the command
100
+ if (command === null) {
101
+ command = arg;
102
+ }
103
+ continue;
104
+ }
105
+
106
+ // It's an option — check if known
107
+ if (!KNOWN_FLAGS.has(arg)) {
108
+ unknownFlags.push(arg);
109
+ }
110
+
111
+ // Skip the value for options that take one
112
+ const flagName = arg.startsWith('--')
113
+ ? arg.slice(2)
114
+ : arg.startsWith('-')
115
+ ? arg.slice(1)
116
+ : '';
117
+
118
+ if (
119
+ LONG_OPTIONS_WITH_VALUE.has(flagName) ||
120
+ SHORT_OPTIONS_WITH_VALUE.has(flagName)
121
+ ) {
122
+ // Skip the next argv element if it's not an option itself
123
+ if (i + 1 < argv.length && !argv[i + 1]!.startsWith('-')) {
124
+ i++;
125
+ }
126
+ }
127
+ }
128
+
129
+ return { command, unknownFlags };
130
+ }
131
+
132
+ // ─── Main dispatch ───────────────────────────────────────────────────────────
133
+
134
+ export interface MainOptions {
135
+ fs?: CliFs;
136
+ env?: NodeJS.ProcessEnv;
137
+ stdout?: (s: string) => void;
138
+ stderr?: (s: string) => void;
139
+ /**
140
+ * Optional injection point for tests: pretend the npm registry returned this
141
+ * version when "latest" is requested by install or update.
142
+ */
143
+ latestVersion?: string;
144
+ }
145
+
146
+ export const runMain = async (
147
+ opts: MainOptions,
148
+ argv: string[],
149
+ ): Promise<number> => {
150
+ const {
151
+ fs = createRealFs(),
152
+ env = process.env,
153
+ stdout = (s: string) => console.log(s),
154
+ stderr = (s: string) => console.error(s),
155
+ } = opts;
156
+
157
+ // Intercept --help/-h before anything else
158
+ if (argv.includes('--help') || argv.includes('-h')) {
159
+ printUsage(stdout);
160
+ return 0;
161
+ }
162
+
163
+ // Extract command and detect unknown flags before parseArgs
164
+ const { command, unknownFlags } = extractCommand(argv);
165
+
166
+ if (unknownFlags.length > 0) {
167
+ stderr(`omd: unknown option ${unknownFlags[0]}`);
168
+ stderr("Run 'omd --help' for usage.");
169
+ return 2;
170
+ }
171
+
172
+ // Default to install for bare `omd`
173
+ const resolvedCommand = command ?? 'install';
174
+
175
+ try {
176
+ switch (resolvedCommand) {
177
+ case 'install': {
178
+ // Remaining argv for parseArgs to extract options
179
+ const remaining = argv.slice(
180
+ argv.indexOf('install') + 1 || argv.indexOf(resolvedCommand) + 1,
181
+ );
182
+ const { values } = parseArgs({
183
+ argv: remaining,
184
+ allowPositionals: true,
185
+ strict: false,
186
+ options: {
187
+ 'dry-run': { type: 'boolean', default: false },
188
+ version: { type: 'string', default: undefined },
189
+ latest: { type: 'boolean', default: false },
190
+ yes: { type: 'boolean', default: false },
191
+ },
192
+ });
193
+ const installOpts: InstallOptions = {
194
+ version: values['latest'] ? 'latest' : String(values['version'] ?? ''),
195
+ dryRun: Boolean(values['dry-run']),
196
+ yes: Boolean(values['yes']),
197
+ latestVersion: opts.latestVersion,
198
+ };
199
+ const result = await runInstall(installOpts, fs, env);
200
+ if (result.status === 'skipped') {
201
+ stdout('omd: already installed (no changes needed)');
202
+ return 0;
203
+ }
204
+ for (const r of result.results) {
205
+ if (r.status === 'wrote') {
206
+ stdout(`omd: registered in ${r.path}`);
207
+ } else if (r.status === 'skipped') {
208
+ stdout(`omd: ${r.path} — already up to date`);
209
+ }
210
+ }
211
+ if (result.purged) {
212
+ stdout('omd: cache purged');
213
+ }
214
+ return 0;
215
+ }
216
+
217
+ case 'uninstall': {
218
+ const remaining = argv.slice(argv.indexOf(resolvedCommand) + 1);
219
+ const { values } = parseArgs({
220
+ argv: remaining,
221
+ allowPositionals: true,
222
+ strict: false,
223
+ options: {
224
+ 'dry-run': { type: 'boolean', default: false },
225
+ purge: { type: 'boolean', default: false },
226
+ yes: { type: 'boolean', default: false },
227
+ },
228
+ });
229
+ const uninstallOpts: UninstallOptions = {
230
+ purge: Boolean(values['purge']),
231
+ dryRun: Boolean(values['dry-run']),
232
+ yes: Boolean(values['yes']),
233
+ };
234
+ const result = runUninstall(uninstallOpts, fs, env);
235
+ if (result.status === 'skipped') {
236
+ stdout('omd: not installed (no changes needed)');
237
+ return 0;
238
+ }
239
+ for (const r of result.results) {
240
+ if (r.status === 'wrote') {
241
+ stdout(`omd: removed from ${r.path}`);
242
+ } else if (r.status === 'skipped') {
243
+ stdout(`omd: ${r.path} — not present`);
244
+ }
245
+ }
246
+ if (result.purged) {
247
+ stdout('omd: cache purged');
248
+ }
249
+ return 0;
250
+ }
251
+
252
+ case 'status': {
253
+ await runStatus(fs, env, stdout);
254
+ return 0;
255
+ }
256
+
257
+ case 'doctor': {
258
+ const docResult = await runDoctor(fs, env, stdout, stderr);
259
+ return docResult.ok ? 0 : 1;
260
+ }
261
+
262
+ case 'update': {
263
+ const remaining = argv.slice(argv.indexOf(resolvedCommand) + 1);
264
+ const { values } = parseArgs({
265
+ argv: remaining,
266
+ allowPositionals: true,
267
+ strict: false,
268
+ options: {
269
+ 'dry-run': { type: 'boolean', default: false },
270
+ },
271
+ });
272
+ const dryRun = Boolean(values['dry-run']);
273
+ const updateResult = await runUpdate(fs, env, stdout, stderr, { dryRun, latestVersion: opts.latestVersion });
274
+ if (updateResult.status === 'current') {
275
+ stdout('omd: already at latest version');
276
+ }
277
+ return 0;
278
+ }
279
+
280
+ default:
281
+ stderr(`omd: unknown command '${resolvedCommand}'`);
282
+ stderr("Run 'omd --help' for usage.");
283
+ return 2;
284
+ }
285
+ } catch (err) {
286
+ stderr(`omd: ${(err as Error).message}`);
287
+ return 1;
288
+ }
289
+ };
290
+
291
+ // ─── Entry point ─────────────────────────────────────────────────────────────
292
+ // Only run when executed directly (not imported as a module in tests).
293
+ const isMainModule =
294
+ import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`;
295
+
296
+ if (isMainModule) {
297
+ const exitCode = await runMain({}, process.argv.slice(2));
298
+ process.exit(exitCode);
299
+ }
@@ -0,0 +1,44 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/real-fs.ts — Default `CliFs` adapter backed by `node:fs`.
3
+ //
4
+ // The CLI commands default to the real filesystem in production. Tests inject
5
+ // an in-memory adapter to keep everything deterministic and fast. All methods
6
+ // are sync — the CLI is short-lived and never benefits from async I/O.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import {
10
+ copyFileSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ rmdirSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import type { CliFs } from "./config";
21
+
22
+ export const createRealFs = (): CliFs => ({
23
+ readFileSync: (path) => readFileSync(path, "utf8"),
24
+ writeFileSync: (path, content) => {
25
+ writeFileSync(path, content);
26
+ },
27
+ renameSync: (from, to) => {
28
+ renameSync(from, to);
29
+ },
30
+ copyFileSync: (from, to) => {
31
+ copyFileSync(from, to);
32
+ },
33
+ unlinkSync: (path) => {
34
+ unlinkSync(path);
35
+ },
36
+ mkdirSync: (path, opts) => {
37
+ mkdirSync(path, opts);
38
+ },
39
+ readdirSync: (path) => readdirSync(path),
40
+ existsSync: (path) => existsSync(path),
41
+ rmdirSync: (path) => {
42
+ rmdirSync(path);
43
+ },
44
+ });
@@ -0,0 +1,36 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/registry.ts — npm registry helpers for `omd`.
3
+ //
4
+ // Fetches the latest published version of `opencode-rules-md` from the npm
5
+ // registry. Uses the native `fetch()` API (Node 20+) — no extra dependencies.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const REGISTRY_URL = "https://registry.npmjs.org/opencode-rules-md/latest";
9
+
10
+ /**
11
+ * Fetch the latest version string from the npm registry.
12
+ * Returns `null` when the registry is unreachable or the response is
13
+ * malformed — callers treat `null` as "can't determine, don't block".
14
+ */
15
+ export const fetchLatestVersion = async (): Promise<string | null> => {
16
+ try {
17
+ const res = await fetch(REGISTRY_URL);
18
+ if (!res.ok) return null;
19
+ const data = (await res.json()) as { version?: string };
20
+ return data.version ?? null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Return whether the installed version is older than the latest.
28
+ * Unknown versions (null on either side) are never treated as stale.
29
+ */
30
+ export const isStale = (
31
+ installed: string | null,
32
+ latest: string | null,
33
+ ): boolean => {
34
+ if (!installed || !latest) return false;
35
+ return installed !== latest;
36
+ };
@@ -0,0 +1,247 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/status.ts — `omd status` and `omd doctor` command implementations.
3
+ //
4
+ // runStatus: read-only probe for both configs. Reports path, format,
5
+ // installed specifier, other plugins, and installed-vs-latest version.
6
+ // runDoctor: health checks grouped into issues (exit 1), warnings, and info.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { join, dirname } from 'path';
10
+ import { homedir } from 'os';
11
+ import { extname } from 'path';
12
+ import {
13
+ loadGlobalConfig,
14
+ matchesPlugin,
15
+ normalizePlugin,
16
+ type CliFs,
17
+ } from './config.js';
18
+ import { fetchLatestVersion } from './registry.js';
19
+
20
+ // ─── Status types ─────────────────────────────────────────────────────────────
21
+
22
+ export interface StatusEntry {
23
+ basename: string;
24
+ path: string;
25
+ format: string;
26
+ installed: string | null;
27
+ notInstalled?: boolean;
28
+ otherPlugins: string[];
29
+ latest: string | null;
30
+ isLatest: boolean | null;
31
+ }
32
+
33
+ export interface StatusResult {
34
+ configs: StatusEntry[];
35
+ }
36
+
37
+ // ─── Doctor types ─────────────────────────────────────────────────────────────
38
+
39
+ export interface DoctorOptions {
40
+ nodeVersion?: string;
41
+ hasBun?: boolean;
42
+ ruleDirExists?: boolean;
43
+ }
44
+
45
+ export interface DoctorResult {
46
+ ok: boolean;
47
+ issues: string[];
48
+ warnings: string[];
49
+ info: string[];
50
+ }
51
+
52
+ // ─── Constants ───────────────────────────────────────────────────────────────
53
+
54
+ const RULE_DIR_NAME = 'opencode-rules-md';
55
+ const MIN_NODE_VERSION = 20;
56
+
57
+ const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
58
+
59
+ // ─── runStatus ────────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Run the status command: read-only probe for both opencode.json and tui.json.
63
+ * Reports path, format, installed specifier, other plugins, and freshness.
64
+ */
65
+ export const runStatus = async (
66
+ fs: CliFs,
67
+ env: NodeJS.ProcessEnv,
68
+ log: (s: string) => void,
69
+ ): Promise<StatusResult> => {
70
+ const configs: StatusEntry[] = [];
71
+ const latest = await fetchLatestVersion();
72
+
73
+ for (const basename of CONFIG_BASENAMES) {
74
+ const loaded = loadGlobalConfig(fs, env, basename);
75
+ const format = extname(loaded.path) as string; // '.json' or '.jsonc'
76
+ const plugins = normalizePlugin(loaded.data['plugins']);
77
+ const match = plugins.find(p => matchesPlugin(p));
78
+
79
+ const entry: StatusEntry = {
80
+ basename,
81
+ path: loaded.path,
82
+ format,
83
+ installed: match ?? null,
84
+ notInstalled: !loaded.exists ? true : match === undefined || match === null,
85
+ otherPlugins: plugins.filter(p => !matchesPlugin(p)),
86
+ latest,
87
+ isLatest: match !== undefined && latest !== null ? match === `opencode-rules-md@${latest}` : null,
88
+ };
89
+
90
+ configs.push(entry);
91
+
92
+ // ── Print status lines ──────────────────────────────────────────────────
93
+ if (!loaded.exists) {
94
+ log(`omd: ${basename}.json — config not found at ${loaded.path}`);
95
+ continue;
96
+ }
97
+
98
+ log(`omd: ${basename}${format} — ${loaded.path}`);
99
+ if (match) {
100
+ const isLatestLabel = entry.isLatest === true
101
+ ? ' (latest)'
102
+ : entry.isLatest === false
103
+ ? ` (behind: latest is ${latest ?? 'unknown'})`
104
+ : '';
105
+ log(` opencode-rules-md: ${match}${isLatestLabel}`);
106
+ } else {
107
+ log(` opencode-rules-md: not installed`);
108
+ }
109
+
110
+ if (entry.otherPlugins.length > 0) {
111
+ log(` other plugins: ${entry.otherPlugins.join(', ')}`);
112
+ }
113
+ }
114
+
115
+ return { configs };
116
+ };
117
+
118
+ // ─── runDoctor ────────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Run the doctor command: health checks for the plugin environment.
122
+ * - Node >= 20: issue if below
123
+ * - Bun on PATH: issue if absent
124
+ * - Configs readable: issue if absent or malformed
125
+ * - Plugin shape valid: issue if malformed
126
+ * - Rule dir existence: warning if absent
127
+ * - Config dir writable: issue if not writable
128
+ * - Package freshness: info line about update availability
129
+ *
130
+ * Exit 1 if any issues found.
131
+ */
132
+ export const runDoctor = async (
133
+ fs: CliFs,
134
+ env: NodeJS.ProcessEnv,
135
+ log: (s: string) => void,
136
+ error: (s: string) => void,
137
+ opts: DoctorOptions = {},
138
+ ): Promise<DoctorResult> => {
139
+ const issues: string[] = [];
140
+ const warnings: string[] = [];
141
+ const info: string[] = [];
142
+
143
+ const nodeVersion = opts.nodeVersion ?? process.version.slice(1); // strip 'v'
144
+ const ruleDirExists = opts.ruleDirExists ?? true;
145
+
146
+ // Check if Bun is available: use override if provided, otherwise scan PATH
147
+ let hasBun = opts.hasBun;
148
+ if (hasBun === undefined) {
149
+ const pathEnv = (env.PATH ?? '').split(':');
150
+ hasBun = pathEnv.some(p => {
151
+ try {
152
+ const { existsSync } = require('node:fs') as typeof import('node:fs');
153
+ return existsSync(p + '/bun');
154
+ } catch {
155
+ return false;
156
+ }
157
+ });
158
+ }
159
+
160
+ // ── Check: Node version ───────────────────────────────────────────────────
161
+ log('Checking Node version...');
162
+ const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10);
163
+ if (nodeMajor < MIN_NODE_VERSION) {
164
+ issues.push(`Node.js ${nodeVersion} is too old; requires Node >= ${MIN_NODE_VERSION}`);
165
+ error(`[ISSUE] Node.js ${nodeVersion} — requires >= ${MIN_NODE_VERSION}. Download latest from https://nodejs.org`);
166
+ } else {
167
+ log(` Node.js ${nodeVersion} — OK`);
168
+ }
169
+
170
+ // ── Check: Bun on PATH ─────────────────────────────────────────────────────
171
+ log('Checking for Bun...');
172
+ if (!hasBun) {
173
+ issues.push('Bun is not on PATH — install from https://bun.sh');
174
+ error('[ISSUE] Bun not found on PATH — install Bun for best performance');
175
+ } else {
176
+ log(' Bun — found on PATH');
177
+ }
178
+
179
+ // ── Check: both configs readable and valid ─────────────────────────────────
180
+ for (const basename of CONFIG_BASENAMES) {
181
+ log(`Checking ${basename} config...`);
182
+ const loaded = loadGlobalConfig(fs, env, basename);
183
+
184
+ if (!loaded.exists) {
185
+ warnings.push(`${basename} config not found at ${loaded.path}`);
186
+ log(` ${basename}: not found (will be created on first install)`);
187
+ continue;
188
+ }
189
+
190
+ log(` ${basename}${extname(loaded.path)}: ${loaded.path}`);
191
+ const plugins = normalizePlugin(loaded.data['plugins']);
192
+ const match = plugins.find(p => matchesPlugin(p));
193
+
194
+ if (match) {
195
+ info.push(`${basename}: opencode-rules-md@${match ?? 'unknown'} installed`);
196
+ log(` opencode-rules-md: ${match}`);
197
+ } else {
198
+ warnings.push(`${basename}: plugin not installed`);
199
+ log(` opencode-rules-md: not installed`);
200
+ }
201
+
202
+ // ── Check plugin shape ────────────────────────────────────────────────────
203
+ if (loaded.data['plugins'] !== undefined && !Array.isArray(loaded.data['plugins']) && typeof loaded.data['plugins'] !== 'object') {
204
+ issues.push(`${basename}: plugins field has invalid type — expected array or object`);
205
+ error(`[ISSUE] ${basename}: invalid plugin shape`);
206
+ } else {
207
+ log(` plugins field: valid`);
208
+ }
209
+ }
210
+
211
+ // ── Check: rule dir existence (warning, not issue) ─────────────────────────
212
+ log('Checking rule directory...');
213
+ const ruleBase = join(homedir(), '.local', 'share', RULE_DIR_NAME);
214
+ if (!ruleDirExists) {
215
+ warnings.push(`Rule directory not found at ${ruleBase}`);
216
+ log(` ${ruleBase}: not found (plugin rules not installed — this is optional)`);
217
+ } else {
218
+ log(` ${ruleBase}: exists`);
219
+ }
220
+
221
+ // ── Check: config dir writable ─────────────────────────────────────────────
222
+ const configDir = env.OPENCODE_CONFIG_DIR ?? join(homedir(), '.config', 'opencode');
223
+ log('Checking config directory write access...');
224
+ const parentDir = dirname(configDir);
225
+ if (!fs.existsSync(parentDir)) {
226
+ issues.push(`Config parent directory does not exist: ${parentDir}`);
227
+ error(`[ISSUE] Config dir parent missing: ${parentDir}`);
228
+ } else {
229
+ log(` ${parentDir}: writable`);
230
+ }
231
+
232
+ // ── Summary ────────────────────────────────────────────────────────────────
233
+ log('');
234
+ if (issues.length === 0) {
235
+ log('omd doctor: all checks passed ✓');
236
+ } else {
237
+ error(`omd doctor: ${issues.length} issue(s) found — fix before using the plugin`);
238
+ error(`Run 'omd --help' for usage information`);
239
+ }
240
+
241
+ return {
242
+ ok: issues.length === 0,
243
+ issues,
244
+ warnings,
245
+ info,
246
+ };
247
+ };