opencode-agent-skills-md 1.0.1 → 1.1.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 (129) hide show
  1. package/dist/cli.mjs +770 -0
  2. package/dist/plugin.mjs +1138 -0
  3. package/dist/src/cli/config.d.ts +144 -0
  4. package/dist/src/cli/install.d.ts +33 -0
  5. package/dist/src/cli/main.d.ts +11 -0
  6. package/dist/src/cli/real-fs.d.ts +6 -0
  7. package/dist/src/cli/status.d.ts +34 -0
  8. package/dist/src/cli/uninstall.d.ts +22 -0
  9. package/dist/src/host.d.ts +51 -0
  10. package/dist/src/index.d.ts +17 -0
  11. package/dist/src/plugin.d.ts +35 -0
  12. package/dist/src/sdk.d.ts +51 -0
  13. package/dist/src/tools.d.ts +86 -0
  14. package/package.json +48 -18
  15. package/.beads/.local_version +0 -1
  16. package/.beads/README.md +0 -81
  17. package/.beads/config.yaml +0 -61
  18. package/.beads/deletions.jsonl +0 -1
  19. package/.beads/issues.jsonl +0 -64
  20. package/.beads/metadata.json +0 -4
  21. package/.gitattributes +0 -3
  22. package/.github/CODEOWNERS +0 -1
  23. package/.github/copilot-instructions.md +0 -78
  24. package/.github/dependabot.yml +0 -13
  25. package/.github/workflows/release.yml +0 -51
  26. package/.opencode/command/test-compaction.md +0 -9
  27. package/.opencode/command/test-find-skills.md +0 -7
  28. package/.opencode/command/test-read-skill-file.md +0 -14
  29. package/.opencode/command/test-run-skill-script.md +0 -13
  30. package/.opencode/command/test-skills.md +0 -14
  31. package/.opencode/command/test-use-skill.md +0 -10
  32. package/.opencode/skills/git-helper/SKILL.md +0 -65
  33. package/.opencode/skills/test-skill/SKILL.md +0 -43
  34. package/.opencode/skills/test-skill/example-config.json +0 -16
  35. package/.opencode/skills/test-skill/helper-docs.md +0 -29
  36. package/.opencode/skills/test-skill/scripts/echo-args +0 -14
  37. package/.opencode/skills/test-skill/scripts/greet +0 -6
  38. package/AGENTS.md +0 -43
  39. package/CHANGELOG.md +0 -178
  40. package/Justfile +0 -39
  41. package/README.md +0 -220
  42. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +0 -74
  43. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +0 -64
  44. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +0 -75
  45. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +0 -136
  46. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +0 -77
  47. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +0 -89
  48. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +0 -65
  49. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +0 -77
  50. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +0 -65
  51. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +0 -165
  52. package/openspec/specs/core-decoupling/spec.md +0 -110
  53. package/packages/core/package.json +0 -30
  54. package/packages/core/src/content.d.ts +0 -16
  55. package/packages/core/src/content.ts +0 -30
  56. package/packages/core/src/debug.ts +0 -16
  57. package/packages/core/src/discovery.d.ts +0 -86
  58. package/packages/core/src/discovery.ts +0 -257
  59. package/packages/core/src/index.d.ts +0 -20
  60. package/packages/core/src/index.ts +0 -55
  61. package/packages/core/src/match.d.ts +0 -19
  62. package/packages/core/src/match.ts +0 -75
  63. package/packages/core/src/parse.d.ts +0 -26
  64. package/packages/core/src/parse.ts +0 -141
  65. package/packages/core/src/scripts.d.ts +0 -17
  66. package/packages/core/src/scripts.ts +0 -79
  67. package/packages/core/src/search.d.ts +0 -83
  68. package/packages/core/src/search.ts +0 -188
  69. package/packages/core/src/types.d.ts +0 -82
  70. package/packages/core/src/types.ts +0 -131
  71. package/packages/core/src/walk.ts +0 -109
  72. package/packages/core/tests/agnostic.test.ts +0 -346
  73. package/packages/core/tests/content.test.ts +0 -65
  74. package/packages/core/tests/discovery.test.ts +0 -370
  75. package/packages/core/tests/package-boundary.test.ts +0 -310
  76. package/packages/core/tests/parse-trigger.test.ts +0 -282
  77. package/packages/core/tests/search.test.ts +0 -374
  78. package/packages/core/tests/subpath.test.ts +0 -87
  79. package/packages/core/tsconfig.json +0 -10
  80. package/packages/opencode-agent-skills-md/package.json +0 -66
  81. package/packages/opencode-agent-skills-md/rolldown.config.js +0 -47
  82. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +0 -1423
  83. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +0 -66
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +0 -8
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +0 -8
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +0 -8
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +0 -8
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +0 -12
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +0 -8
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +0 -11
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +0 -8
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +0 -2
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +0 -1
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +0 -8
  95. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +0 -8
  96. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +0 -114
  97. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +0 -316
  98. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +0 -315
  99. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +0 -179
  100. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +0 -551
  101. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +0 -66
  102. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +0 -213
  103. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +0 -345
  104. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +0 -72
  105. package/packages/opencode-agent-skills-md/tsconfig.build.json +0 -11
  106. package/packages/opencode-agent-skills-md/tsconfig.json +0 -10
  107. package/plans/001-ci-gate.md +0 -177
  108. package/plans/002-is-path-safe.md +0 -243
  109. package/plans/003-escape-prompts.md +0 -310
  110. package/plans/004-test-security-paths.md +0 -228
  111. package/plans/005-stop-swallowing-errors.md +0 -246
  112. package/plans/006-preserve-jsonc-commas.md +0 -144
  113. package/plans/007-write-before-purge.md +0 -144
  114. package/plans/008-reuse-walkdir-for-list-skill-files.md +0 -164
  115. package/plans/README.md +0 -43
  116. package/pnpm-workspace.yaml +0 -6
  117. package/tests/workspace.test.ts +0 -367
  118. package/tsconfig.json +0 -15
  119. /package/{packages/opencode-agent-skills-md/src → src}/cli/config.ts +0 -0
  120. /package/{packages/opencode-agent-skills-md/src → src}/cli/install.ts +0 -0
  121. /package/{packages/opencode-agent-skills-md/src → src}/cli/main.ts +0 -0
  122. /package/{packages/opencode-agent-skills-md/src → src}/cli/real-fs.ts +0 -0
  123. /package/{packages/opencode-agent-skills-md/src → src}/cli/status.ts +0 -0
  124. /package/{packages/opencode-agent-skills-md/src → src}/cli/uninstall.ts +0 -0
  125. /package/{packages/opencode-agent-skills-md/src → src}/host.ts +0 -0
  126. /package/{packages/opencode-agent-skills-md/src → src}/index.ts +0 -0
  127. /package/{packages/opencode-agent-skills-md/src → src}/plugin.ts +0 -0
  128. /package/{packages/opencode-agent-skills-md/src → src}/sdk.ts +0 -0
  129. /package/{packages/opencode-agent-skills-md/src → src}/tools.ts +0 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,770 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { parseArgs } from "node:util";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, join } from "node:path";
7
+ //#region src/cli/config.ts
8
+ /** npm package name for this plugin. */
9
+ const PLUGIN_NAME = "opencode-agent-skills-md";
10
+ /** Filename used for the OpenCode global config (preferred). */
11
+ const CONFIG_FILE_BASENAME = "opencode";
12
+ /** Subdirectory under the user config root that holds `opencode.json`. */
13
+ const OPENCODE_CONFIG_SUBDIR = "opencode";
14
+ /**
15
+ * Resolve the parent directory that holds the global OpenCode config.
16
+ * `$OPENCODE_CONFIG_DIR` wins; otherwise we fall back to
17
+ * `$HOME/.config/opencode` (or `os.homedir()` as a last-resort).
18
+ *
19
+ * Exposed separately so tests and the rotation helper can reuse it
20
+ * without re-deriving the precedence rules.
21
+ */
22
+ const resolveConfigDir = (env = process.env) => {
23
+ const explicit = env.OPENCODE_CONFIG_DIR;
24
+ if (typeof explicit === "string" && explicit.trim().length > 0) return explicit;
25
+ const home = env.HOME;
26
+ if (typeof home === "string" && home.trim().length > 0) return join(home, ".config", OPENCODE_CONFIG_SUBDIR);
27
+ return join(homedir(), ".config", OPENCODE_CONFIG_SUBDIR);
28
+ };
29
+ /**
30
+ * Resolve the global OpenCode config file path.
31
+ *
32
+ * Precedence:
33
+ * 1. If `$OPENCODE_CONFIG_DIR/opencode.json` exists, use it.
34
+ * 2. Else if `$OPENCODE_CONFIG_DIR/opencode.jsonc` exists, use it.
35
+ * 3. Else fall back to `$HOME/.config/opencode/opencode.json`, then `.jsonc`.
36
+ * 4. If nothing exists, return the preferred target `.json` in the
37
+ * resolved directory so `install` knows where to create the file.
38
+ *
39
+ * `.json` always wins over `.jsonc` when both exist.
40
+ */
41
+ const resolveGlobalConfigPath = (fs, env = process.env) => {
42
+ const primaryDir = resolveConfigDir(env);
43
+ const explicit = env.OPENCODE_CONFIG_DIR;
44
+ const hasExplicit = typeof explicit === "string" && explicit.trim().length > 0 && explicit !== primaryDir;
45
+ const fallbackDir = computeFallbackDir(env);
46
+ const json = (dir) => ({
47
+ path: join(dir, `${CONFIG_FILE_BASENAME}.json`),
48
+ format: "json"
49
+ });
50
+ const jsonc = (dir) => ({
51
+ path: join(dir, `${CONFIG_FILE_BASENAME}.jsonc`),
52
+ format: "jsonc"
53
+ });
54
+ const candidateFns = [json, jsonc];
55
+ const dirs = hasExplicit ? [primaryDir] : fallbackDir && fallbackDir !== primaryDir ? [primaryDir, fallbackDir] : [primaryDir];
56
+ for (const dir of dirs) for (const fn of candidateFns) {
57
+ const candidate = fn(dir);
58
+ if (fs.existsSync(candidate.path)) return {
59
+ path: candidate.path,
60
+ format: candidate.format,
61
+ existed: true
62
+ };
63
+ }
64
+ return {
65
+ ...json(dirs[0]),
66
+ existed: false
67
+ };
68
+ };
69
+ const computeFallbackDir = (env) => {
70
+ const explicit = env.OPENCODE_CONFIG_DIR;
71
+ if (typeof explicit === "string" && explicit.trim().length > 0) return null;
72
+ const home = env.HOME;
73
+ if (typeof home === "string" && home.trim().length > 0) return join(home, ".config", OPENCODE_CONFIG_SUBDIR);
74
+ return join(homedir(), ".config", OPENCODE_CONFIG_SUBDIR);
75
+ };
76
+ /**
77
+ * Strip JSONC-style comments and trailing commas, then parse with `JSON.parse`.
78
+ *
79
+ * Returns `{}` for empty input or whitespace-only input.
80
+ * **Throws** on malformed JSON — callers must handle the error to avoid
81
+ * silently overwriting a corrupt config with an empty one.
82
+ */
83
+ const parseJsonc = (text) => {
84
+ const trimmed = text.trim();
85
+ if (trimmed.length === 0) return {};
86
+ const stripped = stripJsoncComments(trimmed);
87
+ const parsed = JSON.parse(stripped);
88
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("config root must be a JSON object");
89
+ return parsed;
90
+ };
91
+ /**
92
+ * Internal: walk the input, dropping `// ...` and `/* ... * /` comments
93
+ * while preserving everything inside string literals (including strings
94
+ * that contain URL slashes). Trailing commas are removed after the
95
+ * comment pass.
96
+ */
97
+ const stripJsoncComments = (text) => {
98
+ let out = "";
99
+ let inString = false;
100
+ let escaped = false;
101
+ let i = 0;
102
+ const len = text.length;
103
+ while (i < len) {
104
+ const c = text[i];
105
+ if (inString) {
106
+ out += c;
107
+ if (escaped) escaped = false;
108
+ else if (c === "\\") escaped = true;
109
+ else if (c === "\"") inString = false;
110
+ i++;
111
+ continue;
112
+ }
113
+ if (c === "\"") {
114
+ inString = true;
115
+ out += c;
116
+ i++;
117
+ continue;
118
+ }
119
+ if (c === "/" && text[i + 1] === "/") {
120
+ i += 2;
121
+ while (i < len && text[i] !== "\n") i++;
122
+ continue;
123
+ }
124
+ if (c === "/" && text[i + 1] === "*") {
125
+ i += 2;
126
+ while (i < len && !(text[i] === "*" && text[i + 1] === "/")) i++;
127
+ i += 2;
128
+ continue;
129
+ }
130
+ out += c;
131
+ i++;
132
+ }
133
+ let result = "";
134
+ let inStr = false;
135
+ let esc = false;
136
+ let j = 0;
137
+ const outLen = out.length;
138
+ while (j < outLen) {
139
+ const ch = out[j];
140
+ if (inStr) {
141
+ result += ch;
142
+ if (esc) esc = false;
143
+ else if (ch === "\\") esc = true;
144
+ else if (ch === "\"") inStr = false;
145
+ j++;
146
+ continue;
147
+ }
148
+ if (ch === "\"") {
149
+ inStr = true;
150
+ result += ch;
151
+ j++;
152
+ continue;
153
+ }
154
+ if (ch === ",") {
155
+ let k = j + 1;
156
+ while (k < outLen && /\s/.test(out[k])) k++;
157
+ if (k < outLen && /[}\]]/.test(out[k])) {
158
+ j = k;
159
+ continue;
160
+ }
161
+ }
162
+ result += ch;
163
+ j++;
164
+ }
165
+ return result;
166
+ };
167
+ /**
168
+ * True when `entry` is a string that resolves to this plugin by base name.
169
+ * Matches `opencode-agent-skills-md` and any `opencode-agent-skills-md@<spec>`
170
+ * variant. Non-string entries (legacy object-form leftover) return false.
171
+ */
172
+ const matchesPlugin = (entry) => {
173
+ if (typeof entry !== "string") return false;
174
+ const at = entry.indexOf("@");
175
+ return (at === -1 ? entry : entry.slice(0, at)) === PLUGIN_NAME;
176
+ };
177
+ /**
178
+ * Coerce the raw value of `config.plugin` into a clean string array.
179
+ *
180
+ * Handles:
181
+ * - `undefined` / `null` → `[]`
182
+ * - array of strings (or mixed) → only the string entries
183
+ * - the broken object form `{ "<name>": ... }` → the keys (in declaration order)
184
+ * - any other non-object, non-array shape → `[]` (doctor surfaces it)
185
+ */
186
+ const normalizePlugin = (raw) => {
187
+ if (raw === void 0 || raw === null) return [];
188
+ if (Array.isArray(raw)) {
189
+ const out = [];
190
+ for (const item of raw) if (typeof item === "string") out.push(item);
191
+ return out;
192
+ }
193
+ if (typeof raw === "object") return Object.keys(raw);
194
+ return [];
195
+ };
196
+ /**
197
+ * Dedupe the plugin list by base name (the part before the first `@`),
198
+ * keeping the LAST occurrence of each base. Any `opencode-agent-skills-md`
199
+ * entries are removed entirely so the install flow can append one fresh
200
+ * entry at the end without leaving stale versions behind.
201
+ *
202
+ * Order is preserved for the surviving entries (last-wins per base).
203
+ */
204
+ const dedupePlugins = (entries) => {
205
+ const filtered = [];
206
+ for (const entry of entries) if (!matchesPlugin(entry)) filtered.push(entry);
207
+ const byBase = /* @__PURE__ */ new Map();
208
+ for (const raw of filtered) {
209
+ if (typeof raw !== "string" || raw.length === 0) continue;
210
+ const at = raw.indexOf("@");
211
+ const base = at === -1 ? raw : raw.slice(0, at);
212
+ if (base.length === 0) continue;
213
+ byBase.set(base, raw);
214
+ }
215
+ return Array.from(byBase.values());
216
+ };
217
+ /**
218
+ * Build the npm specifier we will write into `plugin[]`:
219
+ * `"opencode-agent-skills-md"` when no version is supplied, otherwise
220
+ * `"opencode-agent-skills-md@<version>"`. Empty / whitespace-only versions
221
+ * are treated as "no version".
222
+ */
223
+ const buildSpecifier = (version) => {
224
+ if (typeof version !== "string") return PLUGIN_NAME;
225
+ const trimmed = version.trim();
226
+ if (trimmed.length === 0) return PLUGIN_NAME;
227
+ return `${PLUGIN_NAME}@${trimmed}`;
228
+ };
229
+ /**
230
+ * If `configPath` already exists, copy it next to itself as a timestamped
231
+ * sibling and prune older CLI-created backups so at most `BACKUP_LIMIT`
232
+ * survive (newest first). Returns the backup path, or `null` when no
233
+ * backup was needed (file missing or not writable).
234
+ */
235
+ const backupIfWritable = (configPath, fs) => {
236
+ if (!fs.existsSync(configPath)) return null;
237
+ const backupPath = join(dirname(configPath), `${basename(configPath)}.bak.${backupTimestamp(/* @__PURE__ */ new Date())}`);
238
+ fs.copyFileSync(configPath, backupPath);
239
+ try {
240
+ rotateBackups(configPath, 3, fs);
241
+ } catch {}
242
+ return backupPath;
243
+ };
244
+ /**
245
+ * Prune CLI-created backups of `configPath`, keeping only the newest
246
+ * `limit` siblings (lexical order on the timestamp suffix is fine because
247
+ * the stamp is fixed-width and ISO-8601-derived).
248
+ */
249
+ const rotateBackups = (configPath, limit, fs) => {
250
+ if (limit < 1) return;
251
+ const dir = dirname(configPath);
252
+ const prefix = `${basename(configPath)}.bak.`;
253
+ const backups = fs.readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
254
+ if (backups.length <= limit) return;
255
+ const toRemove = backups.slice(0, backups.length - limit);
256
+ for (const oldName of toRemove) fs.unlinkSync(join(dir, oldName));
257
+ };
258
+ /**
259
+ * Internal: build a filesystem-safe, chronologically-sortable timestamp
260
+ * for backup filenames. Format: `YYYYMMDDTHHmmssSSSZ` — fixed-width, no
261
+ * colons (Windows-safe), and lexical-sortable from newest to oldest.
262
+ */
263
+ const backupTimestamp = (date) => {
264
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
265
+ return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}${pad(date.getUTCMilliseconds(), 3)}Z`;
266
+ };
267
+ /**
268
+ * Write `content` to `targetPath` via a temp sibling + rename. The
269
+ * rename is atomic on POSIX (and best-effort on Windows), so a crashed
270
+ * CLI never leaves a half-written config behind. Any parent directories
271
+ * are created with `{ recursive: true }` so first-run installs Just Work.
272
+ */
273
+ const writeAtomically = (targetPath, content, fs) => {
274
+ const dir = dirname(targetPath);
275
+ fs.mkdirSync(dir, { recursive: true });
276
+ const tmp = `${targetPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
277
+ try {
278
+ fs.writeFileSync(tmp, content);
279
+ fs.renameSync(tmp, targetPath);
280
+ } catch (err) {
281
+ try {
282
+ fs.unlinkSync(tmp);
283
+ } catch {}
284
+ throw err;
285
+ }
286
+ };
287
+ /**
288
+ * Resolve the global config path, read it (if it exists), and parse it.
289
+ * Missing files yield `config = {}` and `existed = false` so the install
290
+ * flow can treat "fresh install" and "already installed" the same way.
291
+ *
292
+ * Malformed JSONC is surfaced via `LoadedConfig.parseError`. Commands MUST
293
+ * check this field and abort instead of silently overwriting the user's
294
+ * corrupt config with an empty one.
295
+ */
296
+ const loadGlobalConfig = (fs, env = process.env) => {
297
+ const resolved = resolveGlobalConfigPath(fs, env);
298
+ if (!resolved.existed) return {
299
+ path: resolved.path,
300
+ config: {},
301
+ existed: false
302
+ };
303
+ const raw = fs.readFileSync(resolved.path);
304
+ try {
305
+ return {
306
+ path: resolved.path,
307
+ config: parseJsonc(raw),
308
+ existed: true
309
+ };
310
+ } catch (err) {
311
+ return {
312
+ path: resolved.path,
313
+ config: {},
314
+ existed: true,
315
+ parseError: err.message
316
+ };
317
+ }
318
+ };
319
+ //#endregion
320
+ //#region src/cli/real-fs.ts
321
+ /**
322
+ * Build a `CliFs` that delegates to `node:fs`. All methods are sync; the
323
+ * CLI is short-lived and never benefits from async I/O.
324
+ */
325
+ const createRealFs = () => ({
326
+ readFileSync: (path) => readFileSync(path, "utf8"),
327
+ writeFileSync: (path, content) => {
328
+ writeFileSync(path, content);
329
+ },
330
+ renameSync: (from, to) => {
331
+ renameSync(from, to);
332
+ },
333
+ copyFileSync: (from, to) => {
334
+ copyFileSync(from, to);
335
+ },
336
+ unlinkSync: (path) => {
337
+ unlinkSync(path);
338
+ },
339
+ mkdirSync: (path, opts) => {
340
+ mkdirSync(path, opts);
341
+ },
342
+ readdirSync: (path) => readdirSync(path),
343
+ existsSync: (path) => existsSync(path)
344
+ });
345
+ //#endregion
346
+ //#region src/cli/install.ts
347
+ const JSON_INDENT$1 = 2;
348
+ /**
349
+ * Run `oas install` against the global OpenCode config.
350
+ *
351
+ * Steps: load → normalize → drop existing oas variants → dedupe surviving
352
+ * entries → append one requested specifier → backup → atomic write. The
353
+ * backup is a timestamped sibling of the config file; rotation to
354
+ * `BACKUP_LIMIT` is handled inside `backupIfWritable`.
355
+ *
356
+ * Idempotency: re-running with the same specifier resolves to a `noop`
357
+ * result without touching disk. A malformed config triggers an error so
358
+ * the user can fix the JSONC instead of silently losing it to an empty
359
+ * overwrite.
360
+ */
361
+ const runInstall = (opts = {}, fs = createRealFs()) => {
362
+ const specifier = buildSpecifier(opts.version);
363
+ const loaded = loadGlobalConfig(fs);
364
+ if (loaded.parseError) throw new Error(`oas: config file is malformed JSON — aborting to avoid data loss.\n path: ${loaded.path}\n error: ${loaded.parseError}\nFix the JSON error, or delete the file and re-run to create a fresh config.`);
365
+ const config = { ...loaded.config };
366
+ const existing = normalizePlugin(config.plugin);
367
+ const finalPlugins = [...dedupePlugins(existing.filter((entry) => !matchesPlugin(entry))), specifier];
368
+ if (!opts.dryRun && JSON.stringify(finalPlugins) === JSON.stringify(existing)) {
369
+ console.log(`✓ Already installed (${specifier}) at ${loaded.path}`);
370
+ return {
371
+ status: "noop",
372
+ path: loaded.path,
373
+ specifier,
374
+ backup: null
375
+ };
376
+ }
377
+ config.plugin = finalPlugins;
378
+ if (opts.dryRun) {
379
+ console.log(`[dry-run] Would write to ${loaded.path}:`);
380
+ console.log(JSON.stringify(config, null, JSON_INDENT$1));
381
+ return {
382
+ status: "planned",
383
+ path: loaded.path,
384
+ specifier,
385
+ backup: null
386
+ };
387
+ }
388
+ const backup = backupIfWritable(loaded.path, fs);
389
+ writeAtomically(loaded.path, JSON.stringify(config, null, JSON_INDENT$1), fs);
390
+ console.log(`✓ Installed ${specifier}`);
391
+ console.log(` config: ${loaded.path}`);
392
+ if (backup) console.log(` backup: ${backup}`);
393
+ return {
394
+ status: "wrote",
395
+ path: loaded.path,
396
+ specifier,
397
+ backup
398
+ };
399
+ };
400
+ //#endregion
401
+ //#region src/cli/status.ts
402
+ const formatFromPath = (path) => path.endsWith(".jsonc") ? "jsonc" : "json";
403
+ /**
404
+ * Read-only status probe. Prints a human-readable report to stdout and
405
+ * returns the same data as a structured result so callers (including
406
+ * `main.ts` and tests) can consume it without parsing the message.
407
+ */
408
+ const runStatus = (fs = createRealFs()) => {
409
+ const loaded = loadGlobalConfig(fs);
410
+ const plugins = normalizePlugin(loaded.config.plugin);
411
+ const oasEntries = plugins.filter(matchesPlugin);
412
+ const extras = plugins.filter((entry) => !matchesPlugin(entry));
413
+ const format = formatFromPath(loaded.path);
414
+ console.log(`Config path: ${loaded.path}`);
415
+ console.log(`Format: ${format}`);
416
+ console.log(`Exists on disk: ${loaded.existed ? "yes" : "no (will be created on install)"}`);
417
+ if (oasEntries.length === 0) {
418
+ console.log(`Installed: no`);
419
+ return {
420
+ installed: false,
421
+ path: loaded.path,
422
+ format,
423
+ specifier: null,
424
+ extras
425
+ };
426
+ }
427
+ const specifier = oasEntries[0] ?? null;
428
+ console.log(`Installed: yes`);
429
+ console.log(`Specifier: ${specifier}`);
430
+ if (extras.length > 0) console.log(`Other plugins: ${extras.join(", ")}`);
431
+ return {
432
+ installed: true,
433
+ path: loaded.path,
434
+ format,
435
+ specifier,
436
+ extras
437
+ };
438
+ };
439
+ /**
440
+ * Health checks. The function does not exit on its own — it returns a
441
+ * `DoctorResult` and `main.ts` maps `ok === false` to exit code 1.
442
+ */
443
+ const runDoctor = (fs = createRealFs(), env = process.env) => {
444
+ const issues = [];
445
+ const warnings = [];
446
+ const info = [];
447
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
448
+ if (!Number.isFinite(nodeMajor) || nodeMajor < 18) issues.push(`Node ${process.versions.node} detected — ${PLUGIN_NAME} requires Node >= 18`);
449
+ else info.push(`Node ${process.versions.node} OK`);
450
+ const loaded = loadGlobalConfig(fs, env);
451
+ const format = formatFromPath(loaded.path);
452
+ info.push(`Config path: ${loaded.path}`);
453
+ info.push(`Config format: ${format}`);
454
+ if (!loaded.existed) warnings.push(`Config file does not exist yet — install will create it`);
455
+ const rawPlugin = loaded.config.plugin;
456
+ if (rawPlugin === void 0 || rawPlugin === null) info.push(`Plugin entries: 0`);
457
+ else if (!(Array.isArray(rawPlugin) || typeof rawPlugin === "object")) issues.push(`config.plugin is neither array nor object — install will reset it`);
458
+ else {
459
+ const plugins = normalizePlugin(rawPlugin);
460
+ info.push(`Plugin entries: ${plugins.length}`);
461
+ const oasCount = plugins.filter(matchesPlugin).length;
462
+ if (oasCount > 1) warnings.push(`${oasCount} ${PLUGIN_NAME} entries present — install will dedupe`);
463
+ }
464
+ try {
465
+ const dir = dirname(loaded.path);
466
+ try {
467
+ if (statSync(dir).isDirectory()) try {
468
+ accessSync(dir, constants.W_OK);
469
+ info.push(`Config directory writable: ${dir}`);
470
+ } catch {
471
+ warnings.push(`Config directory ${dir} is not writable`);
472
+ }
473
+ else issues.push(`${dir} exists but is not a directory`);
474
+ } catch {
475
+ warnings.push(`Config directory ${dir} does not exist yet — will be created on first install`);
476
+ }
477
+ } catch {}
478
+ for (const line of info) console.log(` ✓ ${line}`);
479
+ for (const line of warnings) console.warn(` ! ${line}`);
480
+ for (const line of issues) console.error(` ✗ ${line}`);
481
+ const ok = issues.length === 0;
482
+ if (ok) console.log(`\n✓ Doctor: all checks passed`);
483
+ else console.log(`\n✗ Doctor: ${issues.length} issue(s) found`);
484
+ return {
485
+ ok,
486
+ issues,
487
+ warnings,
488
+ info
489
+ };
490
+ };
491
+ //#endregion
492
+ //#region src/cli/uninstall.ts
493
+ const JSON_INDENT = 2;
494
+ /** Resolve `$HOME` (or `os.homedir()` as last resort) for purge paths. */
495
+ const homeRoot = (env = process.env) => {
496
+ const home = env.HOME;
497
+ if (typeof home === "string" && home.trim().length > 0) return home;
498
+ return homedir();
499
+ };
500
+ /** Bun/npm-style cache path where the plugin gets installed at runtime. */
501
+ const cachePath = (env = process.env) => join(homeRoot(env), ".cache", "opencode", "node_modules", PLUGIN_NAME);
502
+ /** Plugin's own XDG config dir (separate from the OpenCode config it edits). */
503
+ const pluginConfigPath = (env = process.env) => join(homeRoot(env), ".config", PLUGIN_NAME);
504
+ /**
505
+ * Best-effort recursive delete. Returns the path on success or `null` when
506
+ * the target was missing (we don't want to fail the whole command if the
507
+ * user never ran `install` to create these dirs in the first place).
508
+ */
509
+ const purgeDir = (path) => {
510
+ try {
511
+ rmSync(path, {
512
+ recursive: true,
513
+ force: true
514
+ });
515
+ return path;
516
+ } catch {
517
+ return null;
518
+ }
519
+ };
520
+ const runUninstall = (opts = {}, fs = createRealFs()) => {
521
+ const loaded = loadGlobalConfig(fs);
522
+ if (loaded.parseError) throw new Error(`oas: config file is malformed JSON — aborting to avoid data loss.\n path: ${loaded.path}\n error: ${loaded.parseError}\nFix the JSON error, or delete the file and re-run.`);
523
+ const config = { ...loaded.config };
524
+ const existing = normalizePlugin(config.plugin);
525
+ const removed = existing.filter(matchesPlugin);
526
+ const remaining = existing.filter((entry) => !removed.includes(entry));
527
+ const purgeCandidates = opts.purge ? [cachePath(), pluginConfigPath()] : [];
528
+ const purged = [];
529
+ const plannedPurge = [];
530
+ if (opts.purge && opts.dryRun) plannedPurge.push(...purgeCandidates);
531
+ if (removed.length === 0 && purgeCandidates.length === 0) {
532
+ console.log(`✓ Not installed: ${PLUGIN_NAME} not found in ${loaded.path}`);
533
+ return {
534
+ status: "noop",
535
+ path: loaded.path,
536
+ removed: [],
537
+ purged: []
538
+ };
539
+ }
540
+ if (removed.length > 0) if (remaining.length === 0) delete config.plugin;
541
+ else config.plugin = remaining;
542
+ if (opts.dryRun) {
543
+ if (plannedPurge.length > 0) {
544
+ console.log(`[dry-run] Would purge:`);
545
+ for (const p of plannedPurge) console.log(` ${p}`);
546
+ }
547
+ console.log(`[dry-run] Would write to ${loaded.path}:`);
548
+ console.log(JSON.stringify(config, null, JSON_INDENT));
549
+ return {
550
+ status: "planned",
551
+ path: loaded.path,
552
+ removed,
553
+ purged: plannedPurge
554
+ };
555
+ }
556
+ let backup = null;
557
+ if (removed.length > 0 && loaded.existed) {
558
+ backup = backupIfWritable(loaded.path, fs);
559
+ writeAtomically(loaded.path, JSON.stringify(config, null, JSON_INDENT), fs);
560
+ }
561
+ for (const p of purgeCandidates) {
562
+ const result = purgeDir(p);
563
+ if (result) purged.push(result);
564
+ }
565
+ console.log(`✓ Uninstalled ${PLUGIN_NAME}`);
566
+ if (removed.length > 0) console.log(` config: ${loaded.path}`);
567
+ if (backup) console.log(` backup: ${backup}`);
568
+ for (const p of purged) console.log(` purged: ${p}`);
569
+ return {
570
+ status: "wrote",
571
+ path: loaded.path,
572
+ removed,
573
+ purged
574
+ };
575
+ };
576
+ //#endregion
577
+ //#region src/cli/main.ts
578
+ const USAGE = `Usage: oas <command> [options]
579
+
580
+ Commands:
581
+ install Register the plugin in the global OpenCode config
582
+ uninstall Remove the plugin from the global OpenCode config
583
+ status Show current installation status
584
+ doctor Run health checks against the global config
585
+
586
+ Options (install):
587
+ -v, --version <v> Install a specific version (default: latest)
588
+ --latest Alias for --version latest
589
+ --dry-run Print the planned change without writing
590
+ --yes Skip confirmation prompts (reserved)
591
+
592
+ Options (uninstall):
593
+ --purge Also remove cache + ~/.config/opencode-agent-skills-md/
594
+ --dry-run Print the planned change without writing
595
+ --yes Skip confirmation prompts (reserved)
596
+
597
+ Options (all):
598
+ -h, --help Show this help and exit
599
+ `;
600
+ const printUsage = () => {
601
+ console.log(USAGE);
602
+ };
603
+ const setExit = (code) => {
604
+ process.exitCode = code;
605
+ };
606
+ const parseCliArgs = (argv) => {
607
+ const parsed = parseArgs({
608
+ args: argv,
609
+ allowPositionals: true,
610
+ strict: true,
611
+ options: {
612
+ version: {
613
+ type: "string",
614
+ short: "v"
615
+ },
616
+ latest: { type: "boolean" },
617
+ yes: {
618
+ type: "boolean",
619
+ short: "y"
620
+ },
621
+ "dry-run": { type: "boolean" },
622
+ purge: { type: "boolean" },
623
+ help: {
624
+ type: "boolean",
625
+ short: "h"
626
+ }
627
+ }
628
+ });
629
+ return {
630
+ values: parsed.values,
631
+ positionals: parsed.positionals
632
+ };
633
+ };
634
+ /**
635
+ * Strip Node + script argv entries when the entry point is invoked via
636
+ * the shell (shebang) or via `node ./dist/cli.mjs`. When called from a
637
+ * test harness with synthetic args, no stripping happens.
638
+ */
639
+ const sliceProcessArgv = (argv) => {
640
+ if (argv.length < 2) return argv;
641
+ const first = argv[0] ?? "";
642
+ if (first === process.argv[0] || first.endsWith("node") || first.endsWith("node.exe")) return argv.slice(2);
643
+ return argv;
644
+ };
645
+ /**
646
+ * Pure(ish) dispatcher: takes argv, runs the matching command, sets
647
+ * `process.exitCode`, and returns a structured result so tests can assert
648
+ * without reading the exit code.
649
+ */
650
+ const runMain = (argv = process.argv) => {
651
+ const args = sliceProcessArgv(argv);
652
+ if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
653
+ printUsage();
654
+ return {
655
+ command: "help",
656
+ exitCode: 0
657
+ };
658
+ }
659
+ let parsed;
660
+ try {
661
+ parsed = parseCliArgs(args);
662
+ } catch (err) {
663
+ console.error(`oas: ${err.message}`);
664
+ setExit(2);
665
+ return {
666
+ command: null,
667
+ exitCode: 2
668
+ };
669
+ }
670
+ if (parsed.values.help) {
671
+ printUsage();
672
+ return {
673
+ command: "help",
674
+ exitCode: 0
675
+ };
676
+ }
677
+ const command = parsed.positionals[0];
678
+ if (!command) {
679
+ console.error("oas: missing command. Run `oas --help` for usage.");
680
+ setExit(2);
681
+ return {
682
+ command: null,
683
+ exitCode: 2
684
+ };
685
+ }
686
+ try {
687
+ switch (command) {
688
+ case "install": {
689
+ const versionRaw = parsed.values.version;
690
+ runInstall({
691
+ version: parsed.values.latest === true ? "latest" : typeof versionRaw === "string" ? versionRaw : void 0,
692
+ dryRun: parsed.values["dry-run"] === true,
693
+ yes: parsed.values.yes === true
694
+ });
695
+ return {
696
+ command,
697
+ exitCode: 0
698
+ };
699
+ }
700
+ case "uninstall":
701
+ runUninstall({
702
+ purge: parsed.values.purge === true,
703
+ dryRun: parsed.values["dry-run"] === true,
704
+ yes: parsed.values.yes === true
705
+ });
706
+ return {
707
+ command,
708
+ exitCode: 0
709
+ };
710
+ case "status":
711
+ runStatus();
712
+ return {
713
+ command,
714
+ exitCode: 0
715
+ };
716
+ case "doctor": {
717
+ const result = runDoctor();
718
+ if (!result.ok) setExit(1);
719
+ return {
720
+ command,
721
+ exitCode: result.ok ? 0 : 1
722
+ };
723
+ }
724
+ default:
725
+ console.error(`oas: unknown command '${command}'. Run \`oas --help\` for usage.`);
726
+ setExit(2);
727
+ return {
728
+ command: null,
729
+ exitCode: 2
730
+ };
731
+ }
732
+ } catch (err) {
733
+ console.error(`oas: ${err.message}`);
734
+ setExit(1);
735
+ return {
736
+ command,
737
+ exitCode: 1
738
+ };
739
+ }
740
+ };
741
+ /**
742
+ * `true` when the file is the program's entry point (shebang / `node
743
+ * cli.mjs`), `false` when it was imported from a test harness. We avoid
744
+ * `import.meta.main` because the package floor is Node 18 and that field
745
+ * only landed in Node 22.
746
+ *
747
+ * Symlink-aware: when the script is invoked through a symlink (e.g. the
748
+ * pnpm global store, where `node_modules/<pkg>` is a symlink into a
749
+ * content-addressable store), `process.argv[1]` carries the symlink path
750
+ * while `import.meta.url` carries the real path after symlink resolution.
751
+ * Comparing the two verbatim would always be `false` under pnpm, causing
752
+ * the CLI to silently exit. We resolve both sides through `realpathSync`
753
+ * before comparing.
754
+ */
755
+ const checkInvokedAsMain = () => {
756
+ if (!process.argv[1]) return false;
757
+ try {
758
+ const realArgv = pathToFileURL(realpathSync(process.argv[1])).href;
759
+ return import.meta.url === realArgv;
760
+ } catch {
761
+ try {
762
+ return import.meta.url === pathToFileURL(process.argv[1]).href;
763
+ } catch {
764
+ return false;
765
+ }
766
+ }
767
+ };
768
+ if (checkInvokedAsMain()) runMain(process.argv);
769
+ //#endregion
770
+ export { runMain };