opencode-agent-skills-md 1.0.0

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 (117) hide show
  1. package/.beads/.local_version +1 -0
  2. package/.beads/README.md +81 -0
  3. package/.beads/config.yaml +61 -0
  4. package/.beads/deletions.jsonl +1 -0
  5. package/.beads/issues.jsonl +64 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.gitattributes +3 -0
  8. package/.github/CODEOWNERS +1 -0
  9. package/.github/copilot-instructions.md +78 -0
  10. package/.github/dependabot.yml +13 -0
  11. package/.github/workflows/release.yml +51 -0
  12. package/.opencode/command/test-compaction.md +9 -0
  13. package/.opencode/command/test-find-skills.md +7 -0
  14. package/.opencode/command/test-read-skill-file.md +14 -0
  15. package/.opencode/command/test-run-skill-script.md +13 -0
  16. package/.opencode/command/test-skills.md +14 -0
  17. package/.opencode/command/test-use-skill.md +10 -0
  18. package/.opencode/skills/git-helper/SKILL.md +65 -0
  19. package/.opencode/skills/test-skill/SKILL.md +43 -0
  20. package/.opencode/skills/test-skill/example-config.json +16 -0
  21. package/.opencode/skills/test-skill/helper-docs.md +29 -0
  22. package/.opencode/skills/test-skill/scripts/echo-args +14 -0
  23. package/.opencode/skills/test-skill/scripts/greet +6 -0
  24. package/AGENTS.md +43 -0
  25. package/CHANGELOG.md +178 -0
  26. package/Justfile +39 -0
  27. package/LICENSE +9 -0
  28. package/README.md +189 -0
  29. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
  30. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
  31. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
  32. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
  33. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
  34. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
  35. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
  36. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
  37. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
  38. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
  39. package/openspec/specs/core-decoupling/spec.md +110 -0
  40. package/package.json +35 -0
  41. package/packages/core/package.json +30 -0
  42. package/packages/core/src/content.d.ts +16 -0
  43. package/packages/core/src/content.ts +30 -0
  44. package/packages/core/src/debug.ts +16 -0
  45. package/packages/core/src/discovery.d.ts +86 -0
  46. package/packages/core/src/discovery.ts +257 -0
  47. package/packages/core/src/index.d.ts +20 -0
  48. package/packages/core/src/index.ts +55 -0
  49. package/packages/core/src/match.d.ts +19 -0
  50. package/packages/core/src/match.ts +75 -0
  51. package/packages/core/src/parse.d.ts +26 -0
  52. package/packages/core/src/parse.ts +141 -0
  53. package/packages/core/src/scripts.d.ts +17 -0
  54. package/packages/core/src/scripts.ts +79 -0
  55. package/packages/core/src/search.d.ts +83 -0
  56. package/packages/core/src/search.ts +188 -0
  57. package/packages/core/src/types.d.ts +82 -0
  58. package/packages/core/src/types.ts +131 -0
  59. package/packages/core/src/walk.ts +109 -0
  60. package/packages/core/tests/agnostic.test.ts +346 -0
  61. package/packages/core/tests/content.test.ts +65 -0
  62. package/packages/core/tests/discovery.test.ts +370 -0
  63. package/packages/core/tests/package-boundary.test.ts +310 -0
  64. package/packages/core/tests/parse-trigger.test.ts +282 -0
  65. package/packages/core/tests/search.test.ts +374 -0
  66. package/packages/core/tests/subpath.test.ts +87 -0
  67. package/packages/core/tsconfig.json +10 -0
  68. package/packages/opencode-agent-skills-md/package.json +42 -0
  69. package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
  70. package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
  71. package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
  72. package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
  73. package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
  74. package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
  75. package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
  76. package/packages/opencode-agent-skills-md/src/host.ts +119 -0
  77. package/packages/opencode-agent-skills-md/src/index.ts +25 -0
  78. package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
  79. package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
  80. package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
  81. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
  82. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
  83. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
  95. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
  96. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
  97. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
  98. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
  99. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
  100. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
  101. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
  102. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
  103. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
  104. package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
  105. package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
  106. package/plans/001-ci-gate.md +177 -0
  107. package/plans/002-is-path-safe.md +243 -0
  108. package/plans/003-escape-prompts.md +310 -0
  109. package/plans/004-test-security-paths.md +228 -0
  110. package/plans/005-stop-swallowing-errors.md +246 -0
  111. package/plans/006-preserve-jsonc-commas.md +144 -0
  112. package/plans/007-write-before-purge.md +144 -0
  113. package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
  114. package/plans/README.md +43 -0
  115. package/pnpm-workspace.yaml +6 -0
  116. package/tests/workspace.test.ts +367 -0
  117. package/tsconfig.json +15 -0
@@ -0,0 +1,1423 @@
1
+ /**
2
+ * Tests for the `oas` CLI command surface (Phases 1 → 3).
3
+ *
4
+ * Covers:
5
+ * - `parseJsonc`, `normalizePlugin`, `dedupePlugins`, `buildSpecifier`,
6
+ * `matchesPlugin` — pure helpers exercised with handcrafted inputs.
7
+ * - `backupIfWritable`, `rotateBackups`, `writeAtomically` — disk-side
8
+ * helpers exercised against an in-memory `CliFs`.
9
+ * - `loadGlobalConfig`, `resolveGlobalConfigPath` — loader path that
10
+ * uses the injected filesystem and env override.
11
+ * - `runInstall` — fresh install, idempotent re-run with same version,
12
+ * `--dry-run` no-write path, malformed-config abort, dedupe across
13
+ * legacy variants.
14
+ * - `runUninstall` — fresh uninstall, idempotent no-op, partial removal
15
+ * preserving unrelated entries, `--purge` candidate path reporting
16
+ * (dry-run only), `--dry-run` no-write, malformed-config abort.
17
+ * - `runStatus` — installed vs. uninstalled states, `extras` reporting,
18
+ * `.jsonc` format detection.
19
+ * - `runDoctor` — Node version OK path, config shape validation, plugin
20
+ * duplicate-count warning, writability-probe natural "missing dir" path.
21
+ * - `runMain` — valid dispatch (exit 0), missing command (exit 2),
22
+ * unknown command (exit 2), unknown option (exit 2), `--help` / `-h`
23
+ * short-circuit (exit 0).
24
+ *
25
+ * The in-memory adapter (`createMemoryFs`) lives in this file so the tests
26
+ * own its shape and can extend it freely. It mirrors the `CliFs` interface
27
+ * 1:1 — every method is a pure function over the in-memory file map, so
28
+ * tests are deterministic, isolated, and run in milliseconds.
29
+ */
30
+
31
+ import assert from "node:assert/strict";
32
+ import { afterEach, beforeEach, describe, test } from "node:test";
33
+ import {
34
+ BACKUP_LIMIT,
35
+ PLUGIN_NAME,
36
+ backupIfWritable,
37
+ buildSpecifier,
38
+ type CliFs,
39
+ dedupePlugins,
40
+ loadGlobalConfig,
41
+ matchesPlugin,
42
+ normalizePlugin,
43
+ parseJsonc,
44
+ resolveConfigDir,
45
+ resolveGlobalConfigPath,
46
+ rotateBackups,
47
+ writeAtomically,
48
+ } from "../src/cli/config";
49
+ import { runInstall, type InstallOptions } from "../src/cli/install";
50
+ import { runMain, type MainResult } from "../src/cli/main";
51
+ import { type DoctorResult, runDoctor, runStatus, type StatusResult } from "../src/cli/status";
52
+ import { cachePath, pluginConfigPath, runUninstall, type UninstallOptions } from "../src/cli/uninstall";
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // In-memory CliFs
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Build an in-memory `CliFs` adapter.
60
+ *
61
+ * Files are stored as `path → content` string pairs and directories are
62
+ * tracked implicitly: a directory exists iff at least one file below it is
63
+ * recorded. `existsSync` returns true for any recorded file or for any
64
+ * directory that has recorded descendants. `readdirSync` lists files
65
+ * directly under the requested path (one level, matching `node:fs`).
66
+ *
67
+ * Failure injection (write/rename/unlink/copy/mkdir/read) lets tests
68
+ * exercise the cleanup branches without monkey-patching `node:fs`.
69
+ */
70
+ const createMemoryFs = (initial: Record<string, string> = {}): CliFs & {
71
+ files: () => Record<string, string>;
72
+ setFailNext: (op: "rename" | "write" | null) => void;
73
+ } => {
74
+ const files = new Map<string, string>(Object.entries(initial));
75
+ let failNext: "rename" | "write" | null = null;
76
+
77
+ const listDir = (dir: string): string[] => {
78
+ const out = new Set<string>();
79
+ const prefix = dir.endsWith("/") ? dir : `${dir}/`;
80
+ for (const key of files.keys()) {
81
+ if (!key.startsWith(prefix)) continue;
82
+ const rest = key.slice(prefix.length);
83
+ if (rest.length === 0) continue;
84
+ const first = rest.split("/")[0];
85
+ if (first) out.add(first);
86
+ }
87
+ return Array.from(out);
88
+ };
89
+
90
+ const hasAny = (path: string): boolean => {
91
+ if (files.has(path)) return true;
92
+ const prefix = path.endsWith("/") ? path : `${path}/`;
93
+ for (const key of files.keys()) {
94
+ if (key.startsWith(prefix)) return true;
95
+ }
96
+ return false;
97
+ };
98
+
99
+ const fs: CliFs = {
100
+ readFileSync(path) {
101
+ const value = files.get(path);
102
+ if (value === undefined) {
103
+ throw new Error(`ENOENT: no such file '${path}'`);
104
+ }
105
+ return value;
106
+ },
107
+ writeFileSync(path, content) {
108
+ if (failNext === "write") {
109
+ failNext = null;
110
+ throw new Error("synthetic write failure");
111
+ }
112
+ files.set(path, content);
113
+ },
114
+ renameSync(from, to) {
115
+ if (failNext === "rename") {
116
+ failNext = null;
117
+ throw new Error("synthetic rename failure");
118
+ }
119
+ const value = files.get(from);
120
+ if (value === undefined) {
121
+ throw new Error(`ENOENT: no such file '${from}'`);
122
+ }
123
+ files.set(to, value);
124
+ files.delete(from);
125
+ },
126
+ copyFileSync(from, to) {
127
+ const value = files.get(from);
128
+ if (value === undefined) {
129
+ throw new Error(`ENOENT: no such file '${from}'`);
130
+ }
131
+ files.set(to, value);
132
+ },
133
+ unlinkSync(path) {
134
+ if (!files.has(path)) {
135
+ throw new Error(`ENOENT: no such file '${path}'`);
136
+ }
137
+ files.delete(path);
138
+ },
139
+ mkdirSync(_path, _opts) {
140
+ // Implicit: directories exist as soon as a file below them is written.
141
+ },
142
+ readdirSync(path) {
143
+ return listDir(path);
144
+ },
145
+ existsSync(path) {
146
+ return hasAny(path);
147
+ },
148
+ };
149
+
150
+ return {
151
+ ...fs,
152
+ files: () => Object.fromEntries(files.entries()),
153
+ setFailNext: (op) => {
154
+ failNext = op;
155
+ },
156
+ };
157
+ };
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Capture/suppress console.* during CLI runs so test output stays clean.
161
+ // ---------------------------------------------------------------------------
162
+
163
+ type ConsoleSnapshot = {
164
+ log: (...args: unknown[]) => void;
165
+ };
166
+
167
+ const captureConsole = (): { restore: () => void; output: () => string } => {
168
+ let buffer = "";
169
+ const original = console.log as unknown as (...args: unknown[]) => void;
170
+ console.log = (...args: unknown[]) => {
171
+ buffer += `${args.map(String).join(" ")}\n`;
172
+ };
173
+ return {
174
+ restore: () => {
175
+ console.log = original as ConsoleSnapshot["log"];
176
+ },
177
+ output: () => buffer,
178
+ };
179
+ };
180
+
181
+ /**
182
+ * Multi-channel capture for CLI commands that emit on `console.log`,
183
+ * `console.warn`, and `console.error` (e.g. `runStatus`, `runDoctor`,
184
+ * `runMain` error paths). Each accessor returns the cumulative output of
185
+ * its channel at call time — safe to query after the wrapped command has
186
+ * returned but before `restore()` runs.
187
+ */
188
+ const captureConsoleAll = (): {
189
+ restore: () => void;
190
+ log: () => string;
191
+ warn: () => string;
192
+ error: () => string;
193
+ all: () => string;
194
+ } => {
195
+ let logBuf = "";
196
+ let warnBuf = "";
197
+ let errorBuf = "";
198
+ const origLog = console.log;
199
+ const origWarn = console.warn;
200
+ const origError = console.error;
201
+ console.log = ((...args: unknown[]) => {
202
+ logBuf += `${args.map(String).join(" ")}\n`;
203
+ }) as typeof console.log;
204
+ console.warn = ((...args: unknown[]) => {
205
+ warnBuf += `${args.map(String).join(" ")}\n`;
206
+ }) as typeof console.warn;
207
+ console.error = ((...args: unknown[]) => {
208
+ errorBuf += `${args.map(String).join(" ")}\n`;
209
+ }) as typeof console.error;
210
+ return {
211
+ restore: () => {
212
+ console.log = origLog;
213
+ console.warn = origWarn;
214
+ console.error = origError;
215
+ },
216
+ log: () => logBuf,
217
+ warn: () => warnBuf,
218
+ error: () => errorBuf,
219
+ all: () => `${logBuf}${warnBuf}${errorBuf}`,
220
+ };
221
+ };
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Env handling — keep tests hermetic. `resolveConfigDir` reads
225
+ // `process.env`, so each test sets and restores HOME/OPENCODE_CONFIG_DIR.
226
+ // ---------------------------------------------------------------------------
227
+
228
+ const ENV_BACKUP = { ...process.env };
229
+
230
+ const restoreEnv = () => {
231
+ for (const key of new Set([...Object.keys(process.env), ...Object.keys(ENV_BACKUP)])) {
232
+ const original = ENV_BACKUP[key];
233
+ if (original === undefined) {
234
+ delete process.env[key];
235
+ } else {
236
+ process.env[key] = original;
237
+ }
238
+ }
239
+ };
240
+
241
+ beforeEach(() => {
242
+ restoreEnv();
243
+ delete process.env.OPENCODE_CONFIG_DIR;
244
+ // Tests hard-code `/home/x` paths; this keeps `process.env.HOME`-based
245
+ // resolution aligned with those constants.
246
+ process.env.HOME = "/home/x";
247
+ });
248
+
249
+ afterEach(() => {
250
+ restoreEnv();
251
+ });
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Constants
255
+ // ---------------------------------------------------------------------------
256
+
257
+ describe("constants", () => {
258
+ test("PLUGIN_NAME matches the npm package name", () => {
259
+ assert.equal(PLUGIN_NAME, "opencode-agent-skills-md");
260
+ });
261
+
262
+ test("BACKUP_LIMIT is a positive integer", () => {
263
+ assert.ok(Number.isInteger(BACKUP_LIMIT));
264
+ assert.ok(BACKUP_LIMIT >= 1);
265
+ });
266
+ });
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // parseJsonc
270
+ // ---------------------------------------------------------------------------
271
+
272
+ describe("parseJsonc", () => {
273
+ test("parses empty / whitespace-only input as empty object", () => {
274
+ assert.deepEqual(parseJsonc(""), {});
275
+ assert.deepEqual(parseJsonc(" \n\t "), {});
276
+ });
277
+
278
+ test("parses plain JSON", () => {
279
+ assert.deepEqual(parseJsonc('{"plugin":["a","b"]}'), { plugin: ["a", "b"] });
280
+ });
281
+
282
+ test("strips single-line comments", () => {
283
+ const raw = `{
284
+ // list of plugins
285
+ "plugin": ["opencode-agent-skills-md"]
286
+ }`;
287
+ assert.deepEqual(parseJsonc(raw), { plugin: ["opencode-agent-skills-md"] });
288
+ });
289
+
290
+ test("strips block comments", () => {
291
+ const raw = `{
292
+ /* primary plugin list */
293
+ "plugin": ["opencode-agent-skills-md"]
294
+ }`;
295
+ assert.deepEqual(parseJsonc(raw), { plugin: ["opencode-agent-skills-md"] });
296
+ });
297
+
298
+ test("preserves double-slashes inside string literals (URLs)", () => {
299
+ const raw = '{"doc": "see https://example.com/docs for more"}';
300
+ assert.deepEqual(parseJsonc(raw), { doc: "see https://example.com/docs for more" });
301
+ });
302
+
303
+ test("handles escaped quotes inside strings without exiting the string", () => {
304
+ const raw = '{"doc": "escaped \\"quote\\" inside"}';
305
+ assert.deepEqual(parseJsonc(raw), { doc: 'escaped "quote" inside' });
306
+ });
307
+
308
+ test("removes trailing commas before } and ]", () => {
309
+ assert.deepEqual(parseJsonc('{"plugin":["a","b",]}'), { plugin: ["a", "b"] });
310
+ assert.deepEqual(parseJsonc('{"nested":{"x":1,}}'), { nested: { x: 1 } });
311
+ assert.deepEqual(parseJsonc('{"list":[1,2,3,]}'), { list: [1, 2, 3] });
312
+ });
313
+
314
+ test("throws on malformed JSON (caller is expected to handle the error)", () => {
315
+ assert.throws(() => parseJsonc("{ broken"), /JSON|Unexpected|broke/i);
316
+ assert.throws(() => parseJsonc('"just a string"'), /must be a JSON object/i);
317
+ assert.throws(() => parseJsonc("[1,2,3]"), /must be a JSON object/i);
318
+ });
319
+
320
+ test("preserves comma inside a string value before closing brace", () => {
321
+ const raw = '{"doc":"keep ,} inside string","plugin":["a",]}';
322
+ assert.deepEqual(parseJsonc(raw), { doc: "keep ,} inside string", plugin: ["a"] });
323
+ });
324
+
325
+ test("preserves comma inside a string value before closing bracket", () => {
326
+ const raw = '{"doc":"keep ,] inside string","list":[1,2,],}';
327
+ assert.deepEqual(parseJsonc(raw), { doc: "keep ,] inside string", list: [1, 2] });
328
+ });
329
+
330
+ test("preserves mixed patterns: string with comma-bracket, structural trailing commas", () => {
331
+ const raw = `{
332
+ "a": "has ,}",
333
+ "b": ["x", "y ,]",],
334
+ "c": {"d":1,}
335
+ }`;
336
+ assert.deepEqual(parseJsonc(raw), { a: "has ,}", b: ["x", "y ,]"], c: { d: 1 } });
337
+ });
338
+ });
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // matchesPlugin / buildSpecifier
342
+ // ---------------------------------------------------------------------------
343
+
344
+ describe("matchesPlugin", () => {
345
+ test("matches bare PLUGIN_NAME", () => {
346
+ assert.equal(matchesPlugin(PLUGIN_NAME), true);
347
+ });
348
+
349
+ test("matches PLUGIN_NAME with version specifier", () => {
350
+ assert.equal(matchesPlugin(`${PLUGIN_NAME}@1.2.3`), true);
351
+ assert.equal(matchesPlugin(`${PLUGIN_NAME}@latest`), true);
352
+ assert.equal(matchesPlugin(`${PLUGIN_NAME}@next`), true);
353
+ });
354
+
355
+ test("does not match unrelated names or partial prefix matches", () => {
356
+ assert.equal(matchesPlugin("opencode-agent-skills-md-other"), false);
357
+ assert.equal(matchesPlugin("other-plugin"), false);
358
+ assert.equal(matchesPlugin(""), false);
359
+ });
360
+
361
+ test("non-string entries return false (legacy object-form leftovers)", () => {
362
+ assert.equal(matchesPlugin(42), false);
363
+ assert.equal(matchesPlugin(null), false);
364
+ assert.equal(matchesPlugin(undefined), false);
365
+ assert.equal(matchesPlugin({ "opencode-agent-skills-md": {} }), false);
366
+ assert.equal(matchesPlugin(["opencode-agent-skills-md"]), false);
367
+ });
368
+ });
369
+
370
+ describe("buildSpecifier", () => {
371
+ test("returns bare PLUGIN_NAME when no version supplied", () => {
372
+ assert.equal(buildSpecifier(), PLUGIN_NAME);
373
+ assert.equal(buildSpecifier(undefined), PLUGIN_NAME);
374
+ });
375
+
376
+ test("treats empty and whitespace-only versions as 'no version'", () => {
377
+ assert.equal(buildSpecifier(""), PLUGIN_NAME);
378
+ assert.equal(buildSpecifier(" "), PLUGIN_NAME);
379
+ });
380
+
381
+ test("appends @<version> when one is supplied", () => {
382
+ assert.equal(buildSpecifier("1.2.3"), `${PLUGIN_NAME}@1.2.3`);
383
+ assert.equal(buildSpecifier(" 1.2.3 "), `${PLUGIN_NAME}@1.2.3`);
384
+ assert.equal(buildSpecifier("latest"), `${PLUGIN_NAME}@latest`);
385
+ });
386
+
387
+ test("preserves caller-supplied tags and dist-tags", () => {
388
+ assert.equal(buildSpecifier("beta"), `${PLUGIN_NAME}@beta`);
389
+ assert.equal(buildSpecifier("next"), `${PLUGIN_NAME}@next`);
390
+ });
391
+ });
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // normalizePlugin
395
+ // ---------------------------------------------------------------------------
396
+
397
+ describe("normalizePlugin", () => {
398
+ test("returns [] for undefined / null", () => {
399
+ assert.deepEqual(normalizePlugin(undefined), []);
400
+ assert.deepEqual(normalizePlugin(null), []);
401
+ });
402
+
403
+ test("returns [] for non-object, non-array scalars (doctor surfaces these)", () => {
404
+ assert.deepEqual(normalizePlugin(42), []);
405
+ assert.deepEqual(normalizePlugin(true), []);
406
+ assert.deepEqual(normalizePlugin("not-an-array"), []);
407
+ });
408
+
409
+ test("keeps only string entries in an array form", () => {
410
+ const out = normalizePlugin([PLUGIN_NAME, 42, null, "other-plugin"]);
411
+ assert.deepEqual(out, [PLUGIN_NAME, "other-plugin"]);
412
+ });
413
+
414
+ test("converts the legacy object form to its keys, in declaration order", () => {
415
+ const out = normalizePlugin({
416
+ [PLUGIN_NAME]: { foo: 1 },
417
+ "other-plugin": { bar: 2 },
418
+ });
419
+ // Object key ordering is stable in modern engines; we only check the set.
420
+ assert.equal(out.length, 2);
421
+ assert.ok(out.includes(PLUGIN_NAME));
422
+ assert.ok(out.includes("other-plugin"));
423
+ });
424
+ });
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // dedupePlugins
428
+ // ---------------------------------------------------------------------------
429
+
430
+ describe("dedupePlugins", () => {
431
+ test("returns [] for empty input", () => {
432
+ assert.deepEqual(dedupePlugins([]), []);
433
+ });
434
+
435
+ test("drops every PLUGIN_NAME variant", () => {
436
+ const out = dedupePlugins([
437
+ PLUGIN_NAME,
438
+ `${PLUGIN_NAME}@1.0.0`,
439
+ `${PLUGIN_NAME}@2.0.0`,
440
+ "other-plugin",
441
+ ]);
442
+ assert.deepEqual(out, ["other-plugin"]);
443
+ });
444
+
445
+ test("dedupes non-target entries by base name, keeping the LAST occurrence", () => {
446
+ const out = dedupePlugins([
447
+ "alpha@1.0.0",
448
+ "alpha@2.0.0",
449
+ "beta@1.0.0",
450
+ "alpha@3.0.0",
451
+ ]);
452
+ assert.deepEqual(out, ["alpha@3.0.0", "beta@1.0.0"]);
453
+ });
454
+
455
+ test("ignores empty or non-string entries defensively", () => {
456
+ const out = dedupePlugins([PLUGIN_NAME, "", "alpha@1.0.0", null as unknown as string]);
457
+ assert.deepEqual(out, ["alpha@1.0.0"]);
458
+ });
459
+
460
+ test("preserves bare names without a version suffix", () => {
461
+ const out = dedupePlugins(["alpha", PLUGIN_NAME, "alpha@9.9.9"]);
462
+ // last occurrence of "alpha" wins
463
+ assert.deepEqual(out, ["alpha@9.9.9"]);
464
+ });
465
+ });
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Path resolution — resolveConfigDir / resolveGlobalConfigPath
469
+ // ---------------------------------------------------------------------------
470
+
471
+ describe("resolveConfigDir", () => {
472
+ test("OPENCODE_CONFIG_DIR wins", () => {
473
+ assert.equal(
474
+ resolveConfigDir({ OPENCODE_CONFIG_DIR: "/etc/opencode" }),
475
+ "/etc/opencode",
476
+ );
477
+ });
478
+
479
+ test("falls back to $HOME/.config/opencode", () => {
480
+ assert.equal(
481
+ resolveConfigDir({ HOME: "/home/x" }),
482
+ "/home/x/.config/opencode",
483
+ );
484
+ });
485
+
486
+ test("ignores empty / whitespace-only env values", () => {
487
+ assert.equal(
488
+ resolveConfigDir({ OPENCODE_CONFIG_DIR: " ", HOME: "/home/y" }),
489
+ "/home/y/.config/opencode",
490
+ );
491
+ });
492
+ });
493
+
494
+ describe("resolveGlobalConfigPath", () => {
495
+ test("returns the preferred `.json` target when no file exists", () => {
496
+ const fs = createMemoryFs();
497
+ const out = resolveGlobalConfigPath(fs, { HOME: "/home/x", OPENCODE_CONFIG_DIR: "/custom" });
498
+ assert.equal(out.path, "/custom/opencode.json");
499
+ assert.equal(out.format, "json");
500
+ assert.equal(out.existed, false);
501
+ });
502
+
503
+ test("prefers existing `.json` over `.jsonc` in the same directory", () => {
504
+ const fs = createMemoryFs({
505
+ "/home/x/.config/opencode/opencode.json": "{}",
506
+ "/home/x/.config/opencode/opencode.jsonc": "{}",
507
+ });
508
+ const out = resolveGlobalConfigPath(fs, { HOME: "/home/x" });
509
+ assert.equal(out.path, "/home/x/.config/opencode/opencode.json");
510
+ assert.equal(out.format, "json");
511
+ assert.equal(out.existed, true);
512
+ });
513
+
514
+ test("falls back to `.jsonc` when `.json` is missing", () => {
515
+ const fs = createMemoryFs({
516
+ "/home/x/.config/opencode/opencode.jsonc": "{}",
517
+ });
518
+ const out = resolveGlobalConfigPath(fs, { HOME: "/home/x" });
519
+ assert.equal(out.path, "/home/x/.config/opencode/opencode.jsonc");
520
+ assert.equal(out.format, "jsonc");
521
+ assert.equal(out.existed, true);
522
+ });
523
+
524
+ test("$OPENCODE_CONFIG_DIR takes precedence over $HOME", () => {
525
+ const fs = createMemoryFs({
526
+ "/etc/opencode/opencode.json": "{}",
527
+ "/home/x/.config/opencode/opencode.json": "{}",
528
+ });
529
+ const out = resolveGlobalConfigPath(fs, {
530
+ OPENCODE_CONFIG_DIR: "/etc/opencode",
531
+ HOME: "/home/x",
532
+ });
533
+ assert.equal(out.path, "/etc/opencode/opencode.json");
534
+ });
535
+ });
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // loadGlobalConfig
539
+ // ---------------------------------------------------------------------------
540
+
541
+ describe("loadGlobalConfig", () => {
542
+ test("returns { config: {}, existed: false } when no config file exists", () => {
543
+ const fs = createMemoryFs();
544
+ const out = loadGlobalConfig(fs, { HOME: "/home/x" });
545
+ assert.equal(out.existed, false);
546
+ assert.deepEqual(out.config, {});
547
+ assert.match(out.path, /opencode\.json$/);
548
+ });
549
+
550
+ test("parses a normal config and surfaces the parsed object", () => {
551
+ const fs = createMemoryFs({
552
+ "/home/x/.config/opencode/opencode.json": JSON.stringify({ plugin: ["alpha"] }),
553
+ });
554
+ const out = loadGlobalConfig(fs, { HOME: "/home/x" });
555
+ assert.equal(out.existed, true);
556
+ assert.deepEqual(out.config, { plugin: ["alpha"] });
557
+ assert.equal(out.parseError, undefined);
558
+ });
559
+
560
+ test("parses JSONC configs (comments + trailing commas)", () => {
561
+ const raw = `{
562
+ // primary plugin list
563
+ "plugin": ["alpha", /* inline */ "beta",],
564
+ }`;
565
+ const fs = createMemoryFs({
566
+ "/home/x/.config/opencode/opencode.jsonc": raw,
567
+ });
568
+ const out = loadGlobalConfig(fs, { HOME: "/home/x" });
569
+ assert.equal(out.existed, true);
570
+ assert.deepEqual(out.config, { plugin: ["alpha", "beta"] });
571
+ assert.equal(out.parseError, undefined);
572
+ });
573
+
574
+ test("surfaces parseError instead of silently overwriting", () => {
575
+ const fs = createMemoryFs({
576
+ "/home/x/.config/opencode/opencode.json": "{ broken json",
577
+ });
578
+ const out = loadGlobalConfig(fs, { HOME: "/home/x" });
579
+ assert.equal(out.existed, true);
580
+ assert.deepEqual(out.config, {});
581
+ assert.ok(typeof out.parseError === "string" && out.parseError.length > 0);
582
+ });
583
+ });
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // backupIfWritable / rotateBackups
587
+ // ---------------------------------------------------------------------------
588
+
589
+ describe("backupIfWritable", () => {
590
+ test("returns null when the target file does not exist (no backup needed)", () => {
591
+ const fs = createMemoryFs();
592
+ const backup = backupIfWritable("/home/x/.config/opencode/opencode.json", fs);
593
+ assert.equal(backup, null);
594
+ assert.deepEqual(fs.files(), {});
595
+ });
596
+
597
+ test("copies the file to a timestamped sibling and returns the path", () => {
598
+ const target = "/home/x/.config/opencode/opencode.json";
599
+ const fs = createMemoryFs({ [target]: '{"plugin":[]}' });
600
+ const backup = backupIfWritable(target, fs);
601
+ assert.ok(backup);
602
+ assert.ok(backup!.startsWith(`${target}.bak.`));
603
+ assert.equal(fs.files()[backup!], '{"plugin":[]}');
604
+ });
605
+
606
+ test("preserves the original file in addition to creating the backup", () => {
607
+ const target = "/home/x/.config/opencode/opencode.json";
608
+ const fs = createMemoryFs({ [target]: '{"plugin":[]}' });
609
+ backupIfWritable(target, fs);
610
+ assert.equal(fs.files()[target], '{"plugin":[]}');
611
+ });
612
+ });
613
+
614
+ describe("rotateBackups", () => {
615
+ const target = "/home/x/.config/opencode/opencode.json";
616
+ const list = (fs: ReturnType<typeof createMemoryFs>): string[] => {
617
+ const all = Object.keys(fs.files());
618
+ return all.filter((k) => k.includes(".bak.")).map((k) => k.split("/").pop()!);
619
+ };
620
+
621
+ test("keeps at most `limit` backups of the target", () => {
622
+ const fs = createMemoryFs({
623
+ [target]: "{}",
624
+ [`${target}.bak.20260101T000000000Z`]: "{}",
625
+ [`${target}.bak.20260102T000000000Z`]: "{}",
626
+ [`${target}.bak.20260103T000000000Z`]: "{}",
627
+ [`${target}.bak.20260104T000000000Z`]: "{}",
628
+ [`${target}.bak.20260105T000000000Z`]: "{}",
629
+ });
630
+ rotateBackups(target, BACKUP_LIMIT, fs);
631
+ const surviving = list(fs).sort();
632
+ // Only the newest BACKUP_LIMIT (3) backups survive; the two oldest are
633
+ // pruned from the in-memory filesystem.
634
+ assert.deepEqual(surviving, [
635
+ `${target.split("/").pop()}.bak.20260103T000000000Z`,
636
+ `${target.split("/").pop()}.bak.20260104T000000000Z`,
637
+ `${target.split("/").pop()}.bak.20260105T000000000Z`,
638
+ ]);
639
+ });
640
+
641
+ test("does nothing when the directory holds fewer than `limit` backups", () => {
642
+ const fs = createMemoryFs({
643
+ [target]: "{}",
644
+ [`${target}.bak.20260101T000000000Z`]: "{}",
645
+ });
646
+ rotateBackups(target, BACKUP_LIMIT, fs);
647
+ assert.equal(list(fs).length, 1);
648
+ });
649
+
650
+ test("leaves backups of unrelated files alone", () => {
651
+ const fs = createMemoryFs({
652
+ [target]: "{}",
653
+ // 5 backups of `target` plus 1 backup of `other.json` — rotation
654
+ // only touches backups whose prefix matches `target`.
655
+ [`${target}.bak.20260101T000000000Z`]: "{}",
656
+ [`${target}.bak.20260102T000000000Z`]: "{}",
657
+ [`${target}.bak.20260103T000000000Z`]: "{}",
658
+ [`${target}.bak.20260104T000000000Z`]: "{}",
659
+ [`${target}.bak.20260105T000000000Z`]: "{}",
660
+ "/home/x/.config/opencode/other.json.bak.20260109T000000000Z": "{}",
661
+ });
662
+ rotateBackups(target, BACKUP_LIMIT, fs);
663
+ assert.ok(
664
+ fs.files()["/home/x/.config/opencode/other.json.bak.20260109T000000000Z"] !== undefined,
665
+ "unrelated backups must survive",
666
+ );
667
+ });
668
+ });
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // writeAtomically
672
+ // ---------------------------------------------------------------------------
673
+
674
+ describe("writeAtomically", () => {
675
+ const target = "/home/x/.config/opencode/opencode.json";
676
+
677
+ test("writes content to the target path", () => {
678
+ const fs = createMemoryFs();
679
+ writeAtomically(target, '{"plugin":["a"]}', fs);
680
+ assert.equal(fs.files()[target], '{"plugin":["a"]}');
681
+ });
682
+
683
+ test("creates parent directories implicitly (first-run install)", () => {
684
+ const fs = createMemoryFs();
685
+ writeAtomically(target, "{}", fs);
686
+ // The in-memory adapter tracks directories via file presence — the
687
+ // existence check below would resolve the parent if any file under
688
+ // it is recorded.
689
+ assert.equal(fs.existsSync("/home/x/.config/opencode"), true);
690
+ });
691
+
692
+ test("cleans up the temp file when the rename fails", () => {
693
+ const fs = createMemoryFs();
694
+ fs.setFailNext("rename");
695
+ assert.throws(() => writeAtomically(target, '{"plugin":["a"]}', fs));
696
+ // tmp file must NOT linger; only `target` is what callers expect to exist.
697
+ const lingering = Object.keys(fs.files()).filter((k) => k.includes(".tmp-"));
698
+ assert.deepEqual(lingering, []);
699
+ });
700
+ });
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // runInstall
704
+ // ---------------------------------------------------------------------------
705
+
706
+ describe("runInstall", () => {
707
+ const targetPath = "/home/x/.config/opencode/opencode.json";
708
+
709
+ const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
710
+ const fs = createMemoryFs(initial);
711
+ return fs;
712
+ };
713
+
714
+ const env = (): NodeJS.ProcessEnv => ({ HOME: "/home/x" });
715
+
716
+ test("fresh install: appends the bare specifier to an empty plugin list", () => {
717
+ const fs = newFs();
718
+ const captured = captureConsole();
719
+ try {
720
+ const result = runInstall({}, fs as unknown as CliFs);
721
+ assert.equal(result.status, "wrote");
722
+ assert.equal(result.specifier, PLUGIN_NAME);
723
+ assert.equal(result.path, targetPath);
724
+ assert.equal(
725
+ fs.files()[targetPath] ?? "",
726
+ JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2),
727
+ );
728
+ } finally {
729
+ captured.restore();
730
+ }
731
+ });
732
+
733
+ test("fresh install: appends the versioned specifier when version supplied", () => {
734
+ const fs = newFs();
735
+ const captured = captureConsole();
736
+ try {
737
+ const result = runInstall({ version: "2.5.0" }, fs as unknown as CliFs);
738
+ assert.equal(result.specifier, `${PLUGIN_NAME}@2.5.0`);
739
+ assert.equal(
740
+ fs.files()[targetPath] ?? "",
741
+ JSON.stringify({ plugin: [`${PLUGIN_NAME}@2.5.0`] }, null, 2),
742
+ );
743
+ } finally {
744
+ captured.restore();
745
+ }
746
+ });
747
+
748
+ test("fresh install: backed up the original file when one existed", () => {
749
+ const original = JSON.stringify({ plugin: ["other-plugin"] }, null, 2);
750
+ const fs = newFs({ [targetPath]: original });
751
+ const captured = captureConsole();
752
+ try {
753
+ const result = runInstall({}, fs as unknown as CliFs);
754
+ assert.equal(result.status, "wrote");
755
+ assert.ok(result.backup);
756
+ assert.equal(fs.files()[result.backup!], original);
757
+ assert.equal(
758
+ fs.files()[targetPath] ?? "",
759
+ JSON.stringify({ plugin: ["other-plugin", PLUGIN_NAME] }, null, 2),
760
+ );
761
+ } finally {
762
+ captured.restore();
763
+ }
764
+ });
765
+
766
+ test("idempotent: re-running with the same specifier is a no-op", () => {
767
+ const fs = newFs();
768
+ const opts: InstallOptions = {};
769
+ const first = runInstall(opts, fs as unknown as CliFs);
770
+ assert.equal(first.status, "wrote");
771
+ const fileAfterFirst = fs.files()[targetPath] ?? "";
772
+
773
+ const captured = captureConsole();
774
+ try {
775
+ const second = runInstall(opts, fs as unknown as CliFs);
776
+ assert.equal(second.status, "noop");
777
+ assert.equal(second.specifier, first.specifier);
778
+ // No further mutation: the file content is exactly what the first
779
+ // call wrote.
780
+ assert.equal(fs.files()[targetPath] ?? "", fileAfterFirst);
781
+ } finally {
782
+ captured.restore();
783
+ }
784
+ });
785
+
786
+ test("dedupes legacy variants: a fresh install removes any prior target entries", () => {
787
+ const original = JSON.stringify(
788
+ { plugin: [`${PLUGIN_NAME}@0.9.0`, "other", `${PLUGIN_NAME}@1.0.0`] },
789
+ null,
790
+ 2,
791
+ );
792
+ const fs = newFs({ [targetPath]: original });
793
+ const captured = captureConsole();
794
+ try {
795
+ const result = runInstall({}, fs as unknown as CliFs);
796
+ assert.equal(result.status, "wrote");
797
+ // Exactly one `opencode-agent-skills-md` (no version) at the end,
798
+ // after the unrelated `other` plugin.
799
+ assert.equal(
800
+ fs.files()[targetPath] ?? "",
801
+ JSON.stringify({ plugin: ["other", PLUGIN_NAME] }, null, 2),
802
+ );
803
+ } finally {
804
+ captured.restore();
805
+ }
806
+ });
807
+
808
+ test("--dry-run: prints the planned change but writes nothing", () => {
809
+ const fs = newFs();
810
+ const captured = captureConsole();
811
+ try {
812
+ const result = runInstall({ dryRun: true }, fs as unknown as CliFs);
813
+ assert.equal(result.status, "planned");
814
+ assert.equal(result.backup, null);
815
+ // The file must NOT have been created.
816
+ assert.equal(fs.files()[targetPath], undefined);
817
+ assert.match(captured.output(), /\[dry-run\]/);
818
+ assert.match(
819
+ captured.output(),
820
+ new RegExp(PLUGIN_NAME.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")),
821
+ );
822
+ } finally {
823
+ captured.restore();
824
+ }
825
+ });
826
+
827
+ test("malformed config: throws instead of silently overwriting", () => {
828
+ const fs = newFs({ [targetPath]: "{ broken json" });
829
+ const captured = captureConsole();
830
+ try {
831
+ assert.throws(
832
+ () => runInstall({}, fs as unknown as CliFs),
833
+ (err: Error) => {
834
+ assert.match(err.message, /oas:.*malformed JSON/i);
835
+ assert.match(err.message, new RegExp(targetPath.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")));
836
+ return true;
837
+ },
838
+ );
839
+ // The corrupt file must remain intact — install must never overwrite it.
840
+ assert.equal(fs.files()[targetPath], "{ broken json");
841
+ } finally {
842
+ captured.restore();
843
+ }
844
+ });
845
+
846
+ test("malformed config: --dry-run does NOT write a replacement either", () => {
847
+ const fs = newFs({ [targetPath]: "{ broken json" });
848
+ const captured = captureConsole();
849
+ try {
850
+ assert.throws(() => runInstall({ dryRun: true }, fs as unknown as CliFs));
851
+ assert.equal(fs.files()[targetPath], "{ broken json");
852
+ } finally {
853
+ captured.restore();
854
+ }
855
+ });
856
+
857
+ test("respects $OPENCODE_CONFIG_DIR when resolving the target", () => {
858
+ process.env.OPENCODE_CONFIG_DIR = "/etc/opencode";
859
+ const fs = newFs();
860
+ const captured = captureConsole();
861
+ try {
862
+ const result = runInstall({}, fs as unknown as CliFs);
863
+ assert.equal(result.path, "/etc/opencode/opencode.json");
864
+ assert.equal(
865
+ fs.files()[result.path] ?? "",
866
+ JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2),
867
+ );
868
+ } finally {
869
+ captured.restore();
870
+ }
871
+ });
872
+
873
+ test("env is forwarded so tests stay hermetic across the whole suite", () => {
874
+ // The previous test sets OPENCODE_CONFIG_DIR; this test asserts that
875
+ // restoring env in afterEach() returns us to the HOME-based default.
876
+ assert.equal(process.env.OPENCODE_CONFIG_DIR, undefined);
877
+ assert.equal(process.env.HOME, "/home/x");
878
+ });
879
+
880
+ test("uses $HOME-based default after env restoration", () => {
881
+ const fs = newFs();
882
+ const captured = captureConsole();
883
+ try {
884
+ const result = runInstall({}, fs as unknown as CliFs);
885
+ assert.equal(result.path, targetPath);
886
+ } finally {
887
+ captured.restore();
888
+ }
889
+ });
890
+ });
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // runUninstall
894
+ //
895
+ // Plugin-only removal. Helper tests cover:
896
+ // * fresh uninstall — removes the only oas entry and reports `wrote`.
897
+ // * idempotent no-op — when the plugin is absent, returns `noop` and
898
+ // leaves the config untouched.
899
+ // * partial removal — oas + others → oas removed, others preserved.
900
+ // * `--purge` + `--dry-run` — surfaces the candidate purge paths in
901
+ // `result.purged` without touching disk.
902
+ // * `--dry-run` alone — keeps the original file and emits `[dry-run]`
903
+ // console output so the user can review the change.
904
+ // * malformed config — aborts with a clear `oas:` error rather than
905
+ // silently overwriting a corrupted file.
906
+ // ---------------------------------------------------------------------------
907
+
908
+ describe("runUninstall", () => {
909
+ const targetPath = "/home/x/.config/opencode/opencode.json";
910
+
911
+ const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
912
+ return createMemoryFs(initial);
913
+ };
914
+
915
+ const env = (): NodeJS.ProcessEnv => ({ HOME: "/home/x" });
916
+
917
+ test("fresh uninstall: removes the only oas entry and reports wrote", () => {
918
+ const original = JSON.stringify(
919
+ { plugin: [PLUGIN_NAME, "other-plugin"] },
920
+ null,
921
+ 2,
922
+ );
923
+ const fs = newFs({ [targetPath]: original });
924
+ const captured = captureConsole();
925
+ try {
926
+ const result = runUninstall({}, fs as unknown as CliFs);
927
+ assert.equal(result.status, "wrote");
928
+ assert.equal(result.path, targetPath);
929
+ assert.deepEqual(result.removed, [PLUGIN_NAME]);
930
+ assert.deepEqual(result.purged, []);
931
+ // Unrelated entry is preserved.
932
+ assert.equal(
933
+ fs.files()[targetPath] ?? "",
934
+ JSON.stringify({ plugin: ["other-plugin"] }, null, 2),
935
+ );
936
+ } finally {
937
+ captured.restore();
938
+ }
939
+ });
940
+
941
+ test("idempotent no-op: when the plugin is absent, returns noop without writing", () => {
942
+ // Empty config already — uninstall should not touch it.
943
+ const fs = newFs();
944
+ const captured = captureConsole();
945
+ try {
946
+ const result = runUninstall({}, fs as unknown as CliFs);
947
+ assert.equal(result.status, "noop");
948
+ assert.equal(result.path, targetPath);
949
+ assert.deepEqual(result.removed, []);
950
+ assert.deepEqual(result.purged, []);
951
+ // No file was created — `--purge` not requested and the file didn't exist.
952
+ assert.equal(fs.files()[targetPath], undefined);
953
+ } finally {
954
+ captured.restore();
955
+ }
956
+ });
957
+
958
+ test("partial removal: preserves unrelated entries in declaration order", () => {
959
+ const original = JSON.stringify(
960
+ { plugin: ["alpha@1.0.0", PLUGIN_NAME, "beta@1.0.0", `${PLUGIN_NAME}@2.0.0`] },
961
+ null,
962
+ 2,
963
+ );
964
+ const fs = newFs({ [targetPath]: original });
965
+ const captured = captureConsole();
966
+ try {
967
+ const result = runUninstall({}, fs as unknown as CliFs);
968
+ assert.equal(result.status, "wrote");
969
+ // Both oas variants are reported as removed (legacy dedup matches all).
970
+ assert.deepEqual(result.removed.sort(), [PLUGIN_NAME, `${PLUGIN_NAME}@2.0.0`].sort());
971
+ // alpha and beta survive; no oas entries remain.
972
+ assert.equal(
973
+ fs.files()[targetPath] ?? "",
974
+ JSON.stringify({ plugin: ["alpha@1.0.0", "beta@1.0.0"] }, null, 2),
975
+ );
976
+ } finally {
977
+ captured.restore();
978
+ }
979
+ });
980
+
981
+ test("removes the empty `plugin` key when oas was the only entry", () => {
982
+ const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
983
+ const fs = newFs({ [targetPath]: original });
984
+ const captured = captureConsole();
985
+ try {
986
+ const result = runUninstall({}, fs as unknown as CliFs);
987
+ assert.equal(result.status, "wrote");
988
+ // The plugin key should be deleted entirely — leaving `{ plugin: [] }`
989
+ // would change the file shape without need.
990
+ assert.equal(fs.files()[targetPath] ?? "", "{}");
991
+ } finally {
992
+ captured.restore();
993
+ }
994
+ });
995
+
996
+ test("--purge --dry-run: surfaces candidate paths without writing or purging", () => {
997
+ const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
998
+ const fs = newFs({ [targetPath]: original });
999
+ const captured = captureConsole();
1000
+ try {
1001
+ const result = runUninstall({ purge: true, dryRun: true }, fs as unknown as CliFs);
1002
+ assert.equal(result.status, "planned");
1003
+ // The two plugin-owned purge candidates are reported, in declaration order.
1004
+ assert.equal(result.purged.length, 2);
1005
+ assert.ok(result.purged.includes(cachePath(env())));
1006
+ assert.ok(result.purged.includes(pluginConfigPath(env())));
1007
+ // The on-disk config file is unchanged — no write happened.
1008
+ assert.equal(fs.files()[targetPath], original);
1009
+ // Console output mentions `[dry-run]` so the user sees it was a preview.
1010
+ assert.match(captured.output(), /\[dry-run\]/);
1011
+ assert.match(captured.output(), /purge/);
1012
+ } finally {
1013
+ captured.restore();
1014
+ }
1015
+ });
1016
+
1017
+ test("--dry-run alone: keeps the file and reports the planned removal", () => {
1018
+ const original = JSON.stringify({ plugin: [PLUGIN_NAME, "alpha"] }, null, 2);
1019
+ const fs = newFs({ [targetPath]: original });
1020
+ const captured = captureConsole();
1021
+ try {
1022
+ const result = runUninstall({ dryRun: true }, fs as unknown as CliFs);
1023
+ assert.equal(result.status, "planned");
1024
+ assert.deepEqual(result.removed, [PLUGIN_NAME]);
1025
+ assert.deepEqual(result.purged, []);
1026
+ // File is unchanged.
1027
+ assert.equal(fs.files()[targetPath], original);
1028
+ assert.match(captured.output(), /\[dry-run\]/);
1029
+ } finally {
1030
+ captured.restore();
1031
+ }
1032
+ });
1033
+
1034
+ test("malformed config: throws an `oas:` error and never writes", () => {
1035
+ const fs = newFs({ [targetPath]: "{ broken json" });
1036
+ const captured = captureConsole();
1037
+ try {
1038
+ assert.throws(
1039
+ () => runUninstall({}, fs as unknown as CliFs),
1040
+ (err: Error) => {
1041
+ assert.match(err.message, /oas:.*malformed JSON/i);
1042
+ assert.match(
1043
+ err.message,
1044
+ new RegExp(targetPath.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")),
1045
+ );
1046
+ return true;
1047
+ },
1048
+ );
1049
+ // The corrupt file is intact — uninstall never overwrites it.
1050
+ assert.equal(fs.files()[targetPath], "{ broken json");
1051
+ } finally {
1052
+ captured.restore();
1053
+ }
1054
+ });
1055
+
1056
+ test("malformed config: --dry-run also refuses to write a replacement", () => {
1057
+ const fs = newFs({ [targetPath]: "{ broken json" });
1058
+ const captured = captureConsole();
1059
+ try {
1060
+ assert.throws(() => runUninstall({ dryRun: true }, fs as unknown as CliFs));
1061
+ assert.equal(fs.files()[targetPath], "{ broken json");
1062
+ } finally {
1063
+ captured.restore();
1064
+ }
1065
+ });
1066
+
1067
+ test("--purge (real): config write failure leaves config file intact", () => {
1068
+ const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
1069
+ const fs = newFs({ [targetPath]: original });
1070
+ fs.setFailNext("rename");
1071
+ try {
1072
+ assert.throws(() => runUninstall({ purge: true }, fs as unknown as CliFs));
1073
+ assert.equal(fs.files()[targetPath], original);
1074
+ } finally {
1075
+ fs.setFailNext(null);
1076
+ }
1077
+ });
1078
+
1079
+ test("--purge (real): returns `wrote` and surfaces purged paths even when targets are missing", () => {
1080
+ // purgeDir swallows missing-target errors so the command can complete
1081
+ // cleanly even if the user never installed the plugin (no cache dir).
1082
+ const original = JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2);
1083
+ const fs = newFs({ [targetPath]: original });
1084
+ const captured = captureConsole();
1085
+ try {
1086
+ const result = runUninstall({ purge: true }, fs as unknown as CliFs);
1087
+ assert.equal(result.status, "wrote");
1088
+ // The two candidate paths were attempted; since the home cache and
1089
+ // ~/.config/opencode-agent-skills-md both don't exist in the test
1090
+ // sandbox, `purged` will be empty (rmSync with force=true is silenced
1091
+ // by the catch in purgeDir).
1092
+ assert.ok(Array.isArray(result.purged));
1093
+ } finally {
1094
+ captured.restore();
1095
+ }
1096
+ });
1097
+ });
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // runStatus
1101
+ //
1102
+ // Read-only probe. Helper tests cover:
1103
+ // * installed state — oas entry present → `installed: true`,
1104
+ // `specifier` set, `extras` excludes the oas entry.
1105
+ // * uninstalled state — empty `plugin` → `installed: false`,
1106
+ // `specifier: null`.
1107
+ // * extras reporting — non-oas entries surface in `extras`.
1108
+ // * format detection — `.jsonc` config is reported as `jsonc`.
1109
+ // ---------------------------------------------------------------------------
1110
+
1111
+ describe("runStatus", () => {
1112
+ const targetPath = "/home/x/.config/opencode/opencode.json";
1113
+
1114
+ const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
1115
+ return createMemoryFs(initial);
1116
+ };
1117
+
1118
+ test("installed state: oas entry present → installed:true and specifier set", () => {
1119
+ const fs = newFs({
1120
+ [targetPath]: JSON.stringify({ plugin: ["alpha@1.0.0", PLUGIN_NAME] }, null, 2),
1121
+ });
1122
+ const captured = captureConsoleAll();
1123
+ try {
1124
+ const result: StatusResult = runStatus(fs as unknown as CliFs);
1125
+ assert.equal(result.installed, true);
1126
+ assert.equal(result.specifier, PLUGIN_NAME);
1127
+ assert.equal(result.path, targetPath);
1128
+ assert.equal(result.format, "json");
1129
+ // extras excludes the oas entry.
1130
+ assert.deepEqual(result.extras, ["alpha@1.0.0"]);
1131
+ assert.match(captured.log(), new RegExp(`Installed:\\s+yes`));
1132
+ assert.match(captured.log(), new RegExp(`Specifier:\\s+${PLUGIN_NAME.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}`));
1133
+ } finally {
1134
+ captured.restore();
1135
+ }
1136
+ });
1137
+
1138
+ test("versioned specifier is reported verbatim", () => {
1139
+ const fs = newFs({
1140
+ [targetPath]: JSON.stringify({ plugin: [`${PLUGIN_NAME}@2.5.0`] }, null, 2),
1141
+ });
1142
+ const captured = captureConsoleAll();
1143
+ try {
1144
+ const result: StatusResult = runStatus(fs as unknown as CliFs);
1145
+ assert.equal(result.installed, true);
1146
+ assert.equal(result.specifier, `${PLUGIN_NAME}@2.5.0`);
1147
+ } finally {
1148
+ captured.restore();
1149
+ }
1150
+ });
1151
+
1152
+ test("uninstalled state: empty config → installed:false and specifier:null", () => {
1153
+ const fs = newFs();
1154
+ const captured = captureConsoleAll();
1155
+ try {
1156
+ const result: StatusResult = runStatus(fs as unknown as CliFs);
1157
+ assert.equal(result.installed, false);
1158
+ assert.equal(result.specifier, null);
1159
+ assert.deepEqual(result.extras, []);
1160
+ assert.match(captured.log(), new RegExp(`Installed:\\s+no`));
1161
+ } finally {
1162
+ captured.restore();
1163
+ }
1164
+ });
1165
+
1166
+ test("extras reporting: non-oas entries surface alongside the oas entry", () => {
1167
+ const fs = newFs({
1168
+ [targetPath]: JSON.stringify(
1169
+ { plugin: ["alpha@1.0.0", PLUGIN_NAME, "beta@2.0.0"] },
1170
+ null,
1171
+ 2,
1172
+ ),
1173
+ });
1174
+ const captured = captureConsoleAll();
1175
+ try {
1176
+ const result: StatusResult = runStatus(fs as unknown as CliFs);
1177
+ assert.equal(result.installed, true);
1178
+ // Order preserved; oas itself is NOT in extras.
1179
+ assert.deepEqual(result.extras, ["alpha@1.0.0", "beta@2.0.0"]);
1180
+ assert.match(captured.log(), /Other plugins:\s+alpha@1\.0\.0, beta@2\.0\.0/);
1181
+ } finally {
1182
+ captured.restore();
1183
+ }
1184
+ });
1185
+
1186
+ test("extras only: when no oas entry exists, the `extras` field still surfaces unrelated plugins", () => {
1187
+ const fs = newFs({
1188
+ [targetPath]: JSON.stringify({ plugin: ["alpha", "beta"] }, null, 2),
1189
+ });
1190
+ const captured = captureConsoleAll();
1191
+ try {
1192
+ const result: StatusResult = runStatus(fs as unknown as CliFs);
1193
+ assert.equal(result.installed, false);
1194
+ assert.equal(result.specifier, null);
1195
+ // The structured `extras` field captures every non-oas plugin, even
1196
+ // when the plugin itself is absent — scripting callers depend on it.
1197
+ assert.deepEqual(result.extras, ["alpha", "beta"]);
1198
+ // When nothing is installed, runStatus returns early and does not
1199
+ // emit the "Other plugins:" console line — verified by the absence
1200
+ // of that marker here.
1201
+ assert.doesNotMatch(captured.log(), /Other plugins:/);
1202
+ } finally {
1203
+ captured.restore();
1204
+ }
1205
+ });
1206
+
1207
+ test("format detection: a .jsonc config reports format=jsonc", () => {
1208
+ const fs = newFs({
1209
+ "/home/x/.config/opencode/opencode.jsonc": JSON.stringify(
1210
+ { plugin: [PLUGIN_NAME] },
1211
+ null,
1212
+ 2,
1213
+ ),
1214
+ });
1215
+ const captured = captureConsoleAll();
1216
+ try {
1217
+ const result: StatusResult = runStatus(fs as unknown as CliFs);
1218
+ assert.equal(result.format, "jsonc");
1219
+ assert.ok(result.path.endsWith(".jsonc"));
1220
+ assert.match(captured.log(), /Format:\s+jsonc/);
1221
+ } finally {
1222
+ captured.restore();
1223
+ }
1224
+ });
1225
+ });
1226
+
1227
+ // ---------------------------------------------------------------------------
1228
+ // runDoctor
1229
+ //
1230
+ // Health checks; read-only with respect to user config. Helper tests cover:
1231
+ // * Node version check — info line mentions "OK" on the test runner.
1232
+ // * Config shape validation — non-array/non-object `plugin` surfaces
1233
+ // an `issue`.
1234
+ // * Plugin-count warning — duplicate oas entries emit a `warning`.
1235
+ // * Writability probe — when the config dir does not exist, doctor
1236
+ // emits a "does not exist yet" warning. The "not writable" branch is
1237
+ // reached via the same probe but requires POSIX chmod to exercise
1238
+ // reliably; coverage of `statSync` failure here is the natural proof
1239
+ // that the probe runs.
1240
+ // ---------------------------------------------------------------------------
1241
+
1242
+ describe("runDoctor", () => {
1243
+ const targetPath = "/home/x/.config/opencode/opencode.json";
1244
+
1245
+ const newFs = (initial: Record<string, string> = {}): ReturnType<typeof createMemoryFs> => {
1246
+ return createMemoryFs(initial);
1247
+ };
1248
+
1249
+ test("happy path: empty config + writable env → ok=true and Node line is OK", () => {
1250
+ const fs = newFs();
1251
+ const captured = captureConsoleAll();
1252
+ try {
1253
+ const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/home/x" });
1254
+ assert.equal(result.ok, true);
1255
+ assert.deepEqual(result.issues, []);
1256
+ // Node version is recorded as informational (test runner is >= 18).
1257
+ assert.ok(result.info.some((line) => /Node \d+\.\d+\.\d+ OK/.test(line)));
1258
+ } finally {
1259
+ captured.restore();
1260
+ }
1261
+ });
1262
+
1263
+ test("config shape validation: plugin=42 is neither array nor object → issue reported", () => {
1264
+ const fs = newFs({
1265
+ [targetPath]: JSON.stringify({ plugin: 42 }),
1266
+ });
1267
+ const captured = captureConsoleAll();
1268
+ try {
1269
+ const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/home/x" });
1270
+ assert.equal(result.ok, false);
1271
+ assert.ok(
1272
+ result.issues.some((line) => /neither array nor object/.test(line)),
1273
+ `expected an "neither array nor object" issue, got: ${JSON.stringify(result.issues)}`,
1274
+ );
1275
+ } finally {
1276
+ captured.restore();
1277
+ }
1278
+ });
1279
+
1280
+ test("plugin-count warning: multiple oas entries → warning reports dedup-needed", () => {
1281
+ const fs = newFs({
1282
+ [targetPath]: JSON.stringify(
1283
+ { plugin: [PLUGIN_NAME, `${PLUGIN_NAME}@1.0.0`, "other"] },
1284
+ null,
1285
+ 2,
1286
+ ),
1287
+ });
1288
+ const captured = captureConsoleAll();
1289
+ try {
1290
+ const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/home/x" });
1291
+ assert.ok(
1292
+ result.warnings.some((line) => /2 opencode-agent-skills-md entries present/.test(line)),
1293
+ `expected a "2 ... entries present" warning, got: ${JSON.stringify(result.warnings)}`,
1294
+ );
1295
+ // The issue list is still empty — duplicates are non-blocking.
1296
+ assert.deepEqual(result.issues, []);
1297
+ assert.equal(result.ok, true);
1298
+ } finally {
1299
+ captured.restore();
1300
+ }
1301
+ });
1302
+
1303
+ test("writability probe: config directory missing → warning, not issue", () => {
1304
+ // $HOME points to a path that does not exist on the test host; the
1305
+ // probe intentionally fails open with a warning so install can still
1306
+ // surface a real write error when it actually runs.
1307
+ const fs = newFs();
1308
+ const captured = captureConsoleAll();
1309
+ try {
1310
+ const result: DoctorResult = runDoctor(fs as unknown as CliFs, { HOME: "/no/such/home-xyz" });
1311
+ assert.ok(
1312
+ result.warnings.some((line) => /does not exist yet/.test(line)),
1313
+ `expected a "does not exist yet" warning, got: ${JSON.stringify(result.warnings)}`,
1314
+ );
1315
+ // Still no blocking issue — the warning is enough.
1316
+ assert.deepEqual(result.issues, []);
1317
+ } finally {
1318
+ captured.restore();
1319
+ }
1320
+ });
1321
+ });
1322
+
1323
+ // ---------------------------------------------------------------------------
1324
+ // runMain
1325
+ //
1326
+ // CLI dispatch. Helper tests cover all four branches from the spec:
1327
+ // * valid dispatch (exit 0) — `oas status` with no config file on disk.
1328
+ // * invalid usage (exit 2) — missing command.
1329
+ // * invalid usage (exit 2) — unknown command.
1330
+ // * invalid usage (exit 2) — unknown option triggers parseArgs error.
1331
+ // * help flag (exit 0) — both `--help` and `-h`.
1332
+ //
1333
+ // Tests pass synthetic argv like `["status"]` because `sliceProcessArgv`
1334
+ // only strips when `argv[0]` matches `process.argv[0]` or ends in `node`,
1335
+ // which `["status"]` does not. This keeps the helper hermetic.
1336
+ // ---------------------------------------------------------------------------
1337
+
1338
+ describe("runMain", () => {
1339
+ /**
1340
+ * Run `runMain` while capturing every console channel and preserving
1341
+ * `process.exitCode` from any earlier test. Returns the structured
1342
+ * result plus a getter for the captured output.
1343
+ */
1344
+ const dispatch = (
1345
+ argv: readonly string[],
1346
+ ): { result: MainResult; captured: ReturnType<typeof captureConsoleAll> } => {
1347
+ const prevExit = process.exitCode;
1348
+ process.exitCode = undefined;
1349
+ const captured = captureConsoleAll();
1350
+ let result: MainResult;
1351
+ try {
1352
+ result = runMain(argv);
1353
+ } finally {
1354
+ captured.restore();
1355
+ // Restore rather than reset, so a previous test's intent is honored.
1356
+ process.exitCode = prevExit;
1357
+ }
1358
+ return { result, captured };
1359
+ };
1360
+
1361
+ test("valid dispatch: `oas status` exits 0 with the status command resolved", () => {
1362
+ const { result } = dispatch(["status"]);
1363
+ assert.equal(result.command, "status");
1364
+ assert.equal(result.exitCode, 0);
1365
+ });
1366
+
1367
+ test("valid dispatch: `oas doctor` exits 0 when no blocking issues exist", () => {
1368
+ const { result } = dispatch(["doctor"]);
1369
+ assert.equal(result.command, "doctor");
1370
+ // Doctor found no blocking issues (`ok === true`) → exit 0.
1371
+ assert.equal(result.exitCode, 0);
1372
+ });
1373
+
1374
+ test("invalid usage: missing command → exit 2 and a friendly stderr hint", () => {
1375
+ const { result, captured } = dispatch([]);
1376
+ assert.equal(result.command, null);
1377
+ assert.equal(result.exitCode, 2);
1378
+ assert.match(captured.error(), /missing command/i);
1379
+ });
1380
+
1381
+ test("invalid usage: unknown command → exit 2", () => {
1382
+ const { result, captured } = dispatch(["definitely-not-real"]);
1383
+ assert.equal(result.command, null);
1384
+ assert.equal(result.exitCode, 2);
1385
+ assert.match(captured.error(), /unknown command/i);
1386
+ });
1387
+
1388
+ test("invalid usage: unknown option → exit 2 (parseArgs strict error)", () => {
1389
+ const { result, captured } = dispatch(["status", "--bogus-option"]);
1390
+ assert.equal(result.command, null);
1391
+ assert.equal(result.exitCode, 2);
1392
+ assert.match(captured.error(), /oas:|--bogus-option/);
1393
+ });
1394
+
1395
+ test("--help short-circuits to exit 0 before parseArgs runs", () => {
1396
+ const { result, captured } = dispatch(["--help"]);
1397
+ assert.equal(result.command, "help");
1398
+ assert.equal(result.exitCode, 0);
1399
+ assert.match(captured.log(), /Usage: oas/);
1400
+ });
1401
+
1402
+ test("-h short-circuits to exit 0 before parseArgs runs", () => {
1403
+ const { result, captured } = dispatch(["-h"]);
1404
+ assert.equal(result.command, "help");
1405
+ assert.equal(result.exitCode, 0);
1406
+ assert.match(captured.log(), /Usage: oas/);
1407
+ });
1408
+
1409
+ test("--help after a positional still wins and exits 0", () => {
1410
+ const { result, captured } = dispatch(["status", "--help"]);
1411
+ assert.equal(result.command, "help");
1412
+ assert.equal(result.exitCode, 0);
1413
+ assert.match(captured.log(), /Usage: oas/);
1414
+ });
1415
+
1416
+ test("default process.argv when invoked as main is not used in tests (synthetic args only)", () => {
1417
+ // Sanity: dispatching the bare CLI without argv shouldn't see real
1418
+ // process.argv positionals get parsed as commands. We call dispatch
1419
+ // with `[]` (not undefined) to keep the helper hermetic.
1420
+ const { result } = dispatch([]);
1421
+ assert.equal(result.exitCode, 2);
1422
+ });
1423
+ });