opencode-rules-md 0.8.4 → 0.8.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.
Files changed (47) hide show
  1. package/dist/cli.mjs +906 -0
  2. package/dist/src/cli/config.d.ts +104 -0
  3. package/dist/src/cli/config.d.ts.map +1 -0
  4. package/dist/src/cli/config.js +397 -0
  5. package/dist/src/cli/config.js.map +1 -0
  6. package/dist/src/cli/install.d.ts +50 -0
  7. package/dist/src/cli/install.d.ts.map +1 -0
  8. package/dist/src/cli/install.js +67 -0
  9. package/dist/src/cli/install.js.map +1 -0
  10. package/dist/src/cli/main.d.ts +19 -0
  11. package/dist/src/cli/main.d.ts.map +1 -0
  12. package/dist/src/cli/main.js +247 -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/spawn.d.ts +29 -0
  23. package/dist/src/cli/spawn.d.ts.map +1 -0
  24. package/dist/src/cli/spawn.js +48 -0
  25. package/dist/src/cli/spawn.js.map +1 -0
  26. package/dist/src/cli/status.d.ts +51 -0
  27. package/dist/src/cli/status.d.ts.map +1 -0
  28. package/dist/src/cli/status.js +214 -0
  29. package/dist/src/cli/status.js.map +1 -0
  30. package/dist/src/cli/uninstall.d.ts +30 -0
  31. package/dist/src/cli/uninstall.d.ts.map +1 -0
  32. package/dist/src/cli/uninstall.js +128 -0
  33. package/dist/src/cli/uninstall.js.map +1 -0
  34. package/dist/src/cli/update.d.ts +65 -0
  35. package/dist/src/cli/update.d.ts.map +1 -0
  36. package/dist/src/cli/update.js +205 -0
  37. package/dist/src/cli/update.js.map +1 -0
  38. package/package.json +7 -2
  39. package/src/cli/config.ts +480 -0
  40. package/src/cli/install.ts +103 -0
  41. package/src/cli/main.ts +308 -0
  42. package/src/cli/real-fs.ts +44 -0
  43. package/src/cli/registry.ts +36 -0
  44. package/src/cli/spawn.ts +76 -0
  45. package/src/cli/status.ts +293 -0
  46. package/src/cli/uninstall.ts +177 -0
  47. package/src/cli/update.ts +254 -0
@@ -0,0 +1,308 @@
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 via OpenCode's plugin command
26
+ uninstall Remove opencode-rules-md from both configs and cache
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 refresh the install via OpenCode
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/packages/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
+ * Test seam: replace the `spawnOpencodePlugin` function. Lets tests verify
146
+ * which CLI args the installer would have invoked without touching disk.
147
+ */
148
+ spawn?: typeof import('./spawn.js').spawnOpencodePlugin;
149
+ }
150
+
151
+ export const runMain = async (
152
+ opts: MainOptions,
153
+ argv: string[],
154
+ ): Promise<number> => {
155
+ const {
156
+ fs = createRealFs(),
157
+ env = process.env,
158
+ stdout = (s: string) => console.log(s),
159
+ stderr = (s: string) => console.error(s),
160
+ } = opts;
161
+
162
+ // Intercept --help/-h before anything else
163
+ if (argv.includes('--help') || argv.includes('-h')) {
164
+ printUsage(stdout);
165
+ return 0;
166
+ }
167
+
168
+ // Extract command and detect unknown flags before parseArgs
169
+ const { command, unknownFlags } = extractCommand(argv);
170
+
171
+ if (unknownFlags.length > 0) {
172
+ stderr(`omd: unknown option ${unknownFlags[0]}`);
173
+ stderr("Run 'omd --help' for usage.");
174
+ return 2;
175
+ }
176
+
177
+ // Default to install for bare `omd`
178
+ const resolvedCommand = command ?? 'install';
179
+
180
+ try {
181
+ switch (resolvedCommand) {
182
+ case 'install': {
183
+ // Remaining argv for parseArgs to extract options
184
+ const remaining = argv.slice(
185
+ argv.indexOf('install') + 1 || argv.indexOf(resolvedCommand) + 1,
186
+ );
187
+ const { values } = parseArgs({
188
+ args: remaining,
189
+ allowPositionals: true,
190
+ strict: false,
191
+ options: {
192
+ 'dry-run': { type: 'boolean', default: false },
193
+ version: { type: 'string', default: '' },
194
+ latest: { type: 'boolean', default: false },
195
+ yes: { type: 'boolean', default: false },
196
+ },
197
+ });
198
+ const installOpts: InstallOptions = {
199
+ version: values['latest'] ? 'latest' : String(values['version'] ?? ''),
200
+ dryRun: Boolean(values['dry-run']),
201
+ yes: Boolean(values['yes']),
202
+ latestVersion: opts.latestVersion,
203
+ ...(opts.spawn ? { spawn: opts.spawn } : {}),
204
+ };
205
+ const result = await runInstall(installOpts, fs, env);
206
+ if (result.status === 'skipped') {
207
+ stdout(`omd: install skipped (dry-run) — would run: opencode plugin ${result.specifier} --global`);
208
+ return 0;
209
+ }
210
+ stdout(`omd: installed via opencode plugin ${result.specifier} --global`);
211
+ return 0;
212
+ }
213
+
214
+ case 'uninstall': {
215
+ const remaining = argv.slice(argv.indexOf(resolvedCommand) + 1);
216
+ const { values } = parseArgs({
217
+ args: remaining,
218
+ allowPositionals: true,
219
+ strict: false,
220
+ options: {
221
+ 'dry-run': { type: 'boolean', default: false },
222
+ purge: { type: 'boolean', default: false },
223
+ yes: { type: 'boolean', default: false },
224
+ },
225
+ });
226
+ const uninstallOpts: UninstallOptions = {
227
+ purge: Boolean(values['purge']),
228
+ dryRun: Boolean(values['dry-run']),
229
+ yes: Boolean(values['yes']),
230
+ };
231
+ const result = runUninstall(uninstallOpts, fs, env);
232
+ if (result.status === 'skipped') {
233
+ stdout('omd: not installed (no changes needed)');
234
+ return 0;
235
+ }
236
+ for (const r of result.results) {
237
+ if (r.status === 'wrote') {
238
+ stdout(`omd: removed from ${r.path}`);
239
+ } else if (r.status === 'skipped') {
240
+ stdout(`omd: ${r.path} — not present`);
241
+ }
242
+ }
243
+ if (result.purged) {
244
+ stdout('omd: cache purged');
245
+ }
246
+ return 0;
247
+ }
248
+
249
+ case 'status': {
250
+ await runStatus(fs, env, stdout, opts.latestVersion !== undefined ? { latestVersion: opts.latestVersion } : {});
251
+ return 0;
252
+ }
253
+
254
+ case 'doctor': {
255
+ const docResult = await runDoctor(fs, env, stdout, stderr);
256
+ return docResult.ok ? 0 : 1;
257
+ }
258
+
259
+ case 'update': {
260
+ const remaining = argv.slice(argv.indexOf(resolvedCommand) + 1);
261
+ const { values } = parseArgs({
262
+ args: remaining,
263
+ allowPositionals: true,
264
+ strict: false,
265
+ options: {
266
+ 'dry-run': { type: 'boolean', default: false },
267
+ },
268
+ });
269
+ const dryRun = Boolean(values['dry-run']);
270
+ const updateResult = await runUpdate(fs, env, stdout, stderr, {
271
+ dryRun,
272
+ latestVersion: opts.latestVersion,
273
+ ...(opts.spawn ? { spawn: opts.spawn } : {}),
274
+ });
275
+ switch (updateResult.status) {
276
+ case 'current':
277
+ stdout('omd: already at latest version');
278
+ break;
279
+ case 'unreachable':
280
+ stderr('omd: could not reach npm registry — try again later');
281
+ return 1;
282
+ case 'stale':
283
+ // runUpdate already logged the purge / reinstall outcome.
284
+ break;
285
+ }
286
+ return 0;
287
+ }
288
+
289
+ default:
290
+ stderr(`omd: unknown command '${resolvedCommand}'`);
291
+ stderr("Run 'omd --help' for usage.");
292
+ return 2;
293
+ }
294
+ } catch (err) {
295
+ stderr(`omd: ${(err as Error).message}`);
296
+ return 1;
297
+ }
298
+ };
299
+
300
+ // ─── Entry point ─────────────────────────────────────────────────────────────
301
+ // Only run when executed directly (not imported as a module in tests).
302
+ const isMainModule =
303
+ import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`;
304
+
305
+ if (isMainModule) {
306
+ const exitCode = await runMain({}, process.argv.slice(2));
307
+ process.exit(exitCode);
308
+ }
@@ -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,76 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/spawn.ts — thin wrapper around `opencode plugin`.
3
+ //
4
+ // Why a wrapper? Two reasons:
5
+ // 1. OpenCode owns the schema for `data['plugin']` (singular) and the cache
6
+ // layout under ~/.cache/opencode/packages/. Re-implementing that logic
7
+ // drift-prone (the bug we are fixing). Delegating to OpenCode's own CLI
8
+ // keeps us correct by construction.
9
+ // 2. Tests need a deterministic, non-blocking seam. Defaulting to
10
+ // spawnSync with `stdio: 'inherit'` lets the real CLI talk directly to
11
+ // the user's terminal; tests inject a stub that returns canned output.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ export interface SpawnResult {
19
+ status: number | null;
20
+ stdout: string;
21
+ stderr: string;
22
+ }
23
+
24
+ export interface SpawnOptions {
25
+ env: NodeJS.ProcessEnv;
26
+ stdio?: 'pipe' | 'inherit';
27
+ }
28
+
29
+ export type SpawnFn = (command: string, args: string[], options: SpawnOptions) => SpawnResult;
30
+
31
+ export interface SpawnOpencodePluginOptions {
32
+ /** Injected spawn function for tests. Defaults to node:child_process.spawnSync. */
33
+ spawn?: SpawnFn;
34
+ /** Environment variables passed to the child process. Defaults to process.env. */
35
+ env?: NodeJS.ProcessEnv;
36
+ /** stdio mode for the child process. 'inherit' forwards output to the parent. */
37
+ stdio?: 'pipe' | 'inherit';
38
+ }
39
+
40
+ /**
41
+ * Run `opencode plugin <args...>` and return the exit status plus captured
42
+ * stdout/stderr (only populated when `stdio: 'pipe'`).
43
+ *
44
+ * The default implementation uses spawnSync so the call blocks until the
45
+ * child process exits. This keeps `omd install` simple — the user's shell
46
+ * stays put until registration completes, then returns control with a
47
+ * non-zero status on failure.
48
+ */
49
+ export async function spawnOpencodePlugin(
50
+ args: string[],
51
+ opts: SpawnOpencodePluginOptions = {},
52
+ ): Promise<SpawnResult> {
53
+ const env = opts.env ?? process.env;
54
+ const stdio = opts.stdio ?? 'inherit';
55
+ const spawnFn = opts.spawn ?? defaultSpawn;
56
+ return spawnFn('opencode', ['plugin', ...args], { env, stdio });
57
+ }
58
+
59
+ /**
60
+ * Default spawn implementation backed by node:child_process.spawnSync.
61
+ *
62
+ * The require is loaded lazily so test doubles can replace the spawn
63
+ * function entirely without ever touching node:child_process.
64
+ */
65
+ function defaultSpawn(command: string, args: string[], options: SpawnOptions): SpawnResult {
66
+ const cp = require('node:child_process') as typeof import('node:child_process');
67
+ const result = cp.spawnSync(command, args, {
68
+ env: options.env,
69
+ stdio: options.stdio,
70
+ });
71
+ return {
72
+ status: result.status,
73
+ stdout: result.stdout?.toString() ?? '',
74
+ stderr: result.stderr?.toString() ?? '',
75
+ };
76
+ }