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,522 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/config.ts — Discovery, JSONC-safe parse, plugin migration helpers,
3
+ // backup rotation, and atomic writes for the `oas` CLI.
4
+ //
5
+ // The CLI edits only the global OpenCode config (`$OPENCODE_CONFIG_DIR` or
6
+ // `~/.config/opencode/opencode.json[.jsonc]`). Helpers are split so the
7
+ // install / uninstall / status flows stay thin: pure path and merge helpers
8
+ // live here, disk I/O goes through the injected `CliFs` so unit tests can
9
+ // run entirely in-memory.
10
+ //
11
+ // Conventions (PR 1 — Foundation):
12
+ // - All disk I/O is sync. The CLI is short-lived; async buys nothing here
13
+ // and complicates test mock plumbing.
14
+ // - JSONC is stripped before `JSON.parse`; rewritten output is plain JSON.
15
+ // - Plugin migration: legacy object form `{ "<name>": <value> }` is
16
+ // converted to an array of base names before install/uninstall dedup.
17
+ // - Backups are timestamped, kept to the newest `BACKUP_LIMIT` siblings
18
+ // of the config file in the same directory.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, join } from "node:path";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** npm package name for this plugin. */
29
+ export const PLUGIN_NAME = "opencode-agent-skills-md";
30
+
31
+ /** Maximum number of CLI-created backups retained in the config directory. */
32
+ export const BACKUP_LIMIT = 3;
33
+
34
+ /** Filename used for the OpenCode global config (preferred). */
35
+ export const CONFIG_FILE_BASENAME = "opencode";
36
+
37
+ /** Subdirectory under the user config root that holds `opencode.json`. */
38
+ export const OPENCODE_CONFIG_SUBDIR = "opencode";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Filesystem abstraction
42
+ //
43
+ // Sync by design (see file header). Methods mirror the `node:fs` surface
44
+ // we actually use; nothing more so tests stay small.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Minimal synchronous filesystem surface that the `oas` CLI commands rely
49
+ * on. The CLI never imports `node:fs` directly — every disk operation
50
+ * goes through this boundary so tests can run fully in memory.
51
+ *
52
+ * The methods intentionally mirror only the operations the CLI needs:
53
+ * helpers that demand more (e.g. `rmSync` for recursive directory
54
+ * removal during uninstall purge) live outside this interface and call
55
+ * `node:fs` directly at the call site, where they belong.
56
+ */
57
+ export interface CliFs {
58
+ readFileSync(path: string): string;
59
+ writeFileSync(path: string, content: string): void;
60
+ renameSync(from: string, to: string): void;
61
+ copyFileSync(from: string, to: string): void;
62
+ unlinkSync(path: string): void;
63
+ mkdirSync(path: string, opts?: { recursive?: boolean }): void;
64
+ readdirSync(path: string): string[];
65
+ existsSync(path: string): boolean;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Path resolution
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export interface ResolvedConfigPath {
73
+ /** Absolute path to use for reads/writes. `.json` by default. */
74
+ path: string;
75
+ /** "json" when the resolved file ends in `.json`, "jsonc" otherwise. */
76
+ format: "json" | "jsonc";
77
+ /** True when `path` already existed on disk before resolution. */
78
+ existed: boolean;
79
+ }
80
+
81
+ /**
82
+ * Resolve the parent directory that holds the global OpenCode config.
83
+ * `$OPENCODE_CONFIG_DIR` wins; otherwise we fall back to
84
+ * `$HOME/.config/opencode` (or `os.homedir()` as a last-resort).
85
+ *
86
+ * Exposed separately so tests and the rotation helper can reuse it
87
+ * without re-deriving the precedence rules.
88
+ */
89
+ export const resolveConfigDir = (env: NodeJS.ProcessEnv = process.env): string => {
90
+ const explicit = env.OPENCODE_CONFIG_DIR;
91
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
92
+ return explicit;
93
+ }
94
+ const home = env.HOME;
95
+ if (typeof home === "string" && home.trim().length > 0) {
96
+ return join(home, ".config", OPENCODE_CONFIG_SUBDIR);
97
+ }
98
+ return join(homedir(), ".config", OPENCODE_CONFIG_SUBDIR);
99
+ };
100
+
101
+ /**
102
+ * Resolve the global OpenCode config file path.
103
+ *
104
+ * Precedence:
105
+ * 1. If `$OPENCODE_CONFIG_DIR/opencode.json` exists, use it.
106
+ * 2. Else if `$OPENCODE_CONFIG_DIR/opencode.jsonc` exists, use it.
107
+ * 3. Else fall back to `$HOME/.config/opencode/opencode.json`, then `.jsonc`.
108
+ * 4. If nothing exists, return the preferred target `.json` in the
109
+ * resolved directory so `install` knows where to create the file.
110
+ *
111
+ * `.json` always wins over `.jsonc` when both exist.
112
+ */
113
+ export const resolveGlobalConfigPath = (
114
+ fs: CliFs,
115
+ env: NodeJS.ProcessEnv = process.env,
116
+ ): ResolvedConfigPath => {
117
+ const primaryDir = resolveConfigDir(env);
118
+
119
+ const explicit = env.OPENCODE_CONFIG_DIR;
120
+ const hasExplicit =
121
+ typeof explicit === "string" && explicit.trim().length > 0 && explicit !== primaryDir;
122
+
123
+ const fallbackDir = computeFallbackDir(env);
124
+
125
+ const json = (dir: string): { path: string; format: "json" } => ({
126
+ path: join(dir, `${CONFIG_FILE_BASENAME}.json`),
127
+ format: "json",
128
+ });
129
+ const jsonc = (dir: string): { path: string; format: "jsonc" } => ({
130
+ path: join(dir, `${CONFIG_FILE_BASENAME}.jsonc`),
131
+ format: "jsonc",
132
+ });
133
+
134
+ const candidateFns: ((dir: string) => { path: string; format: "json" | "jsonc" })[] = [
135
+ json,
136
+ jsonc,
137
+ ];
138
+ const dirs: string[] = hasExplicit
139
+ ? [primaryDir]
140
+ : fallbackDir && fallbackDir !== primaryDir
141
+ ? [primaryDir, fallbackDir]
142
+ : [primaryDir];
143
+
144
+ for (const dir of dirs) {
145
+ for (const fn of candidateFns) {
146
+ const candidate = fn(dir);
147
+ if (fs.existsSync(candidate.path)) {
148
+ return { path: candidate.path, format: candidate.format, existed: true };
149
+ }
150
+ }
151
+ }
152
+
153
+ // No existing file — return the preferred target (`.json` in primary dir).
154
+ const target = json(dirs[0] as string);
155
+ return { ...target, existed: false };
156
+ };
157
+
158
+ const computeFallbackDir = (env: NodeJS.ProcessEnv): string | null => {
159
+ const explicit = env.OPENCODE_CONFIG_DIR;
160
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
161
+ // OPENCODE_CONFIG_DIR is authoritative; no `$HOME`-based fallback.
162
+ return null;
163
+ }
164
+ const home = env.HOME;
165
+ if (typeof home === "string" && home.trim().length > 0) {
166
+ return join(home, ".config", OPENCODE_CONFIG_SUBDIR);
167
+ }
168
+ return join(homedir(), ".config", OPENCODE_CONFIG_SUBDIR);
169
+ };
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // JSONC stripping
173
+ //
174
+ // Walks the input character-by-character, tracking string state so we never
175
+ // strip `//` that lives inside a JSON string (URLs, "https://...").
176
+ // After stripping comments we also remove trailing commas before `}` or `]`.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Strip JSONC-style comments and trailing commas, then parse with `JSON.parse`.
181
+ *
182
+ * Returns `{}` for empty input or whitespace-only input.
183
+ * **Throws** on malformed JSON — callers must handle the error to avoid
184
+ * silently overwriting a corrupt config with an empty one.
185
+ */
186
+ export const parseJsonc = (text: string): Record<string, unknown> => {
187
+ const trimmed = text.trim();
188
+ if (trimmed.length === 0) return {};
189
+
190
+ const stripped = stripJsoncComments(trimmed);
191
+ const parsed = JSON.parse(stripped) as unknown;
192
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
193
+ throw new Error("config root must be a JSON object");
194
+ }
195
+ return parsed as Record<string, unknown>;
196
+ };
197
+
198
+ /**
199
+ * Internal: walk the input, dropping `// ...` and `/* ... * /` comments
200
+ * while preserving everything inside string literals (including strings
201
+ * that contain URL slashes). Trailing commas are removed after the
202
+ * comment pass.
203
+ */
204
+ const stripJsoncComments = (text: string): string => {
205
+ let out = "";
206
+ let inString = false;
207
+ let escaped = false;
208
+ let i = 0;
209
+ const len = text.length;
210
+
211
+ while (i < len) {
212
+ const c = text[i] as string;
213
+
214
+ if (inString) {
215
+ out += c;
216
+ if (escaped) {
217
+ escaped = false;
218
+ } else if (c === "\\") {
219
+ escaped = true;
220
+ } else if (c === '"') {
221
+ inString = false;
222
+ }
223
+ i++;
224
+ continue;
225
+ }
226
+
227
+ if (c === '"') {
228
+ inString = true;
229
+ out += c;
230
+ i++;
231
+ continue;
232
+ }
233
+
234
+ // Line comment: skip until EOL
235
+ if (c === "/" && text[i + 1] === "/") {
236
+ i += 2;
237
+ while (i < len && text[i] !== "\n") i++;
238
+ continue;
239
+ }
240
+
241
+ // Block comment: skip until closing */
242
+ if (c === "/" && text[i + 1] === "*") {
243
+ i += 2;
244
+ while (i < len && !(text[i] === "*" && text[i + 1] === "/")) i++;
245
+ i += 2; // skip past */
246
+ continue;
247
+ }
248
+
249
+ out += c;
250
+ i++;
251
+ }
252
+
253
+ // Remove trailing commas before } or ] — string-aware second pass
254
+ let result = "";
255
+ let inStr = false;
256
+ let esc = false;
257
+ let j = 0;
258
+ const outLen = out.length;
259
+
260
+ while (j < outLen) {
261
+ const ch = out[j];
262
+
263
+ if (inStr) {
264
+ result += ch;
265
+ if (esc) {
266
+ esc = false;
267
+ } else if (ch === "\\") {
268
+ esc = true;
269
+ } else if (ch === '"') {
270
+ inStr = false;
271
+ }
272
+ j++;
273
+ continue;
274
+ }
275
+
276
+ if (ch === '"') {
277
+ inStr = true;
278
+ result += ch;
279
+ j++;
280
+ continue;
281
+ }
282
+
283
+ if (ch === ",") {
284
+ let k = j + 1;
285
+ while (k < outLen && /\s/.test(out[k]!)) k++;
286
+ if (k < outLen && /[}\]]/.test(out[k]!)) {
287
+ j = k;
288
+ continue;
289
+ }
290
+ }
291
+
292
+ result += ch;
293
+ j++;
294
+ }
295
+
296
+ return result;
297
+ };
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Plugin helpers
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /**
304
+ * True when `entry` is a string that resolves to this plugin by base name.
305
+ * Matches `opencode-agent-skills-md` and any `opencode-agent-skills-md@<spec>`
306
+ * variant. Non-string entries (legacy object-form leftover) return false.
307
+ */
308
+ export const matchesPlugin = (entry: unknown): boolean => {
309
+ if (typeof entry !== "string") return false;
310
+ const at = entry.indexOf("@");
311
+ const base = at === -1 ? entry : entry.slice(0, at);
312
+ return base === PLUGIN_NAME;
313
+ };
314
+
315
+ /**
316
+ * Coerce the raw value of `config.plugin` into a clean string array.
317
+ *
318
+ * Handles:
319
+ * - `undefined` / `null` → `[]`
320
+ * - array of strings (or mixed) → only the string entries
321
+ * - the broken object form `{ "<name>": ... }` → the keys (in declaration order)
322
+ * - any other non-object, non-array shape → `[]` (doctor surfaces it)
323
+ */
324
+ export const normalizePlugin = (raw: unknown): string[] => {
325
+ if (raw === undefined || raw === null) return [];
326
+ if (Array.isArray(raw)) {
327
+ const out: string[] = [];
328
+ for (const item of raw) {
329
+ if (typeof item === "string") out.push(item);
330
+ }
331
+ return out;
332
+ }
333
+ if (typeof raw === "object") {
334
+ const obj = raw as Record<string, unknown>;
335
+ return Object.keys(obj);
336
+ }
337
+ return [];
338
+ };
339
+
340
+ /**
341
+ * Dedupe the plugin list by base name (the part before the first `@`),
342
+ * keeping the LAST occurrence of each base. Any `opencode-agent-skills-md`
343
+ * entries are removed entirely so the install flow can append one fresh
344
+ * entry at the end without leaving stale versions behind.
345
+ *
346
+ * Order is preserved for the surviving entries (last-wins per base).
347
+ */
348
+ export const dedupePlugins = (entries: readonly string[]): string[] => {
349
+ // Strip all target-plugin entries first — they will be re-added by the
350
+ // caller with the requested version. This guarantees at most one target
351
+ // entry survives, regardless of how many variants already exist.
352
+ const filtered: string[] = [];
353
+ for (const entry of entries) {
354
+ if (!matchesPlugin(entry)) filtered.push(entry);
355
+ }
356
+
357
+ // Walk in order, overwriting the same base with the latest variant so
358
+ // "last occurrence wins" falls out naturally. Defensive against
359
+ // non-string entries: callers (e.g. `normalizePlugin`) are supposed to
360
+ // pre-filter, but bad input from a corrupt config must not crash the
361
+ // install pipeline.
362
+ const byBase = new Map<string, string>();
363
+ for (const raw of filtered) {
364
+ if (typeof raw !== "string" || raw.length === 0) continue;
365
+ const at = raw.indexOf("@");
366
+ const base = at === -1 ? raw : raw.slice(0, at);
367
+ if (base.length === 0) continue; // guard against bare "@scope/spec" split artifacts
368
+ byBase.set(base, raw);
369
+ }
370
+ return Array.from(byBase.values());
371
+ };
372
+
373
+ /**
374
+ * Build the npm specifier we will write into `plugin[]`:
375
+ * `"opencode-agent-skills-md"` when no version is supplied, otherwise
376
+ * `"opencode-agent-skills-md@<version>"`. Empty / whitespace-only versions
377
+ * are treated as "no version".
378
+ */
379
+ export const buildSpecifier = (version?: string): string => {
380
+ if (typeof version !== "string") return PLUGIN_NAME;
381
+ const trimmed = version.trim();
382
+ if (trimmed.length === 0) return PLUGIN_NAME;
383
+ return `${PLUGIN_NAME}@${trimmed}`;
384
+ };
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Backups
388
+ // ---------------------------------------------------------------------------
389
+
390
+ /**
391
+ * If `configPath` already exists, copy it next to itself as a timestamped
392
+ * sibling and prune older CLI-created backups so at most `BACKUP_LIMIT`
393
+ * survive (newest first). Returns the backup path, or `null` when no
394
+ * backup was needed (file missing or not writable).
395
+ */
396
+ export const backupIfWritable = (configPath: string, fs: CliFs): string | null => {
397
+ if (!fs.existsSync(configPath)) return null;
398
+
399
+ const dir = dirname(configPath);
400
+ const base = basename(configPath);
401
+ const stamp = backupTimestamp(new Date());
402
+ const backupPath = join(dir, `${base}.bak.${stamp}`);
403
+ fs.copyFileSync(configPath, backupPath);
404
+
405
+ // Rotation is best-effort — a failure here should not abort the install.
406
+ try {
407
+ rotateBackups(configPath, BACKUP_LIMIT, fs);
408
+ } catch {
409
+ // Rotation failed (permission denied, file locked, etc.).
410
+ // The backup was still created; we just have more than BACKUP_LIMIT.
411
+ }
412
+
413
+ return backupPath;
414
+ };
415
+
416
+ /**
417
+ * Prune CLI-created backups of `configPath`, keeping only the newest
418
+ * `limit` siblings (lexical order on the timestamp suffix is fine because
419
+ * the stamp is fixed-width and ISO-8601-derived).
420
+ */
421
+ export const rotateBackups = (configPath: string, limit: number, fs: CliFs): void => {
422
+ if (limit < 1) return;
423
+ const dir = dirname(configPath);
424
+ const base = basename(configPath);
425
+ const prefix = `${base}.bak.`;
426
+ const entries = fs.readdirSync(dir);
427
+ const backups = entries.filter((name) => name.startsWith(prefix)).sort(); // ISO-derived stamp → lexical sort = chronological sort
428
+
429
+ if (backups.length <= limit) return;
430
+ const toRemove = backups.slice(0, backups.length - limit);
431
+ for (const oldName of toRemove) {
432
+ fs.unlinkSync(join(dir, oldName));
433
+ }
434
+ };
435
+
436
+ /**
437
+ * Internal: build a filesystem-safe, chronologically-sortable timestamp
438
+ * for backup filenames. Format: `YYYYMMDDTHHmmssSSSZ` — fixed-width, no
439
+ * colons (Windows-safe), and lexical-sortable from newest to oldest.
440
+ */
441
+ const backupTimestamp = (date: Date): string => {
442
+ const pad = (n: number, w = 2): string => String(n).padStart(w, "0");
443
+ return (
444
+ `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
445
+ `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}` +
446
+ `${pad(date.getUTCMilliseconds(), 3)}Z`
447
+ );
448
+ };
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Atomic write
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Write `content` to `targetPath` via a temp sibling + rename. The
456
+ * rename is atomic on POSIX (and best-effort on Windows), so a crashed
457
+ * CLI never leaves a half-written config behind. Any parent directories
458
+ * are created with `{ recursive: true }` so first-run installs Just Work.
459
+ */
460
+ export const writeAtomically = (targetPath: string, content: string, fs: CliFs): void => {
461
+ const dir = dirname(targetPath);
462
+ fs.mkdirSync(dir, { recursive: true });
463
+ const tmp = `${targetPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
464
+ try {
465
+ fs.writeFileSync(tmp, content);
466
+ fs.renameSync(tmp, targetPath);
467
+ } catch (err) {
468
+ // Clean up the temp file if rename failed — avoids orphaned .tmp-* files.
469
+ try {
470
+ fs.unlinkSync(tmp);
471
+ } catch {
472
+ /* best-effort cleanup */
473
+ }
474
+ throw err;
475
+ }
476
+ };
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // Loader
480
+ // ---------------------------------------------------------------------------
481
+
482
+ export interface LoadedConfig {
483
+ /** Absolute path the loader used (existing or newly-targeted). */
484
+ path: string;
485
+ /** Parsed config object — `{}` if the file was absent or unreadable. */
486
+ config: Record<string, unknown>;
487
+ /** Whether the file existed on disk before loading. */
488
+ existed: boolean;
489
+ /**
490
+ * If the existing config file is malformed JSON, this contains the
491
+ * error message. Commands must check this and abort rather than
492
+ * silently overwriting the corrupt file with an empty config.
493
+ */
494
+ parseError?: string;
495
+ }
496
+
497
+ /**
498
+ * Resolve the global config path, read it (if it exists), and parse it.
499
+ * Missing files yield `config = {}` and `existed = false` so the install
500
+ * flow can treat "fresh install" and "already installed" the same way.
501
+ *
502
+ * Malformed JSONC is surfaced via `LoadedConfig.parseError`. Commands MUST
503
+ * check this field and abort instead of silently overwriting the user's
504
+ * corrupt config with an empty one.
505
+ */
506
+ export const loadGlobalConfig = (fs: CliFs, env: NodeJS.ProcessEnv = process.env): LoadedConfig => {
507
+ const resolved = resolveGlobalConfigPath(fs, env);
508
+ if (!resolved.existed) {
509
+ return { path: resolved.path, config: {}, existed: false };
510
+ }
511
+ const raw = fs.readFileSync(resolved.path);
512
+ try {
513
+ return { path: resolved.path, config: parseJsonc(raw), existed: true };
514
+ } catch (err) {
515
+ return {
516
+ path: resolved.path,
517
+ config: {},
518
+ existed: true,
519
+ parseError: (err as Error).message,
520
+ };
521
+ }
522
+ };
@@ -0,0 +1,111 @@
1
+ // ---------------------------------------------------------------------------
2
+ // src/cli/install.ts — `oas install` command.
3
+ //
4
+ // Edits the global OpenCode config so `plugin` contains exactly one
5
+ // `opencode-agent-skills-md[@version]` entry. The flow is idempotent:
6
+ // existing oas entries are filtered out before the new one is appended,
7
+ // and re-running with the same version is a no-op. With `--dry-run` the
8
+ // pipeline runs end-to-end but no bytes hit disk.
9
+ //
10
+ // The function is pure of side effects beyond what it prints and writes
11
+ // through `fs`. Tests can inject an in-memory `CliFs` to exercise every
12
+ // branch deterministically.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import {
16
+ backupIfWritable,
17
+ buildSpecifier,
18
+ type CliFs,
19
+ dedupePlugins,
20
+ loadGlobalConfig,
21
+ matchesPlugin,
22
+ normalizePlugin,
23
+ writeAtomically,
24
+ } from "./config";
25
+ import { createRealFs } from "./real-fs";
26
+
27
+ export interface InstallOptions {
28
+ /** Optional version pin (e.g. `"1.2.3"`, `"latest"`). Omit for bare specifier. */
29
+ version?: string;
30
+ /** Plan the change and print it without writing. */
31
+ dryRun?: boolean;
32
+ /** Reserved for future confirmation prompts; accepted but unused for now. */
33
+ yes?: boolean;
34
+ }
35
+
36
+ export interface InstallResult {
37
+ /** Outcome of the command. */
38
+ status: "wrote" | "planned" | "noop";
39
+ /** Resolved config path (existing or newly-targeted). */
40
+ path: string;
41
+ /** Specifier that was added (or would be added under `--dry-run`). */
42
+ specifier: string;
43
+ /** Backup path created before the write, or `null` when no backup was needed. */
44
+ backup: string | null;
45
+ }
46
+
47
+ const JSON_INDENT = 2;
48
+
49
+ /**
50
+ * Run `oas install` against the global OpenCode config.
51
+ *
52
+ * Steps: load → normalize → drop existing oas variants → dedupe surviving
53
+ * entries → append one requested specifier → backup → atomic write. The
54
+ * backup is a timestamped sibling of the config file; rotation to
55
+ * `BACKUP_LIMIT` is handled inside `backupIfWritable`.
56
+ *
57
+ * Idempotency: re-running with the same specifier resolves to a `noop`
58
+ * result without touching disk. A malformed config triggers an error so
59
+ * the user can fix the JSONC instead of silently losing it to an empty
60
+ * overwrite.
61
+ */
62
+ export const runInstall = (
63
+ opts: InstallOptions = {},
64
+ fs: CliFs = createRealFs(),
65
+ ): InstallResult => {
66
+ const specifier = buildSpecifier(opts.version);
67
+ const loaded = loadGlobalConfig(fs);
68
+
69
+ if (loaded.parseError) {
70
+ throw new Error(
71
+ `oas: config file is malformed JSON — aborting to avoid data loss.\n` +
72
+ ` path: ${loaded.path}\n` +
73
+ ` error: ${loaded.parseError}\n` +
74
+ `Fix the JSON error, or delete the file and re-run to create a fresh config.`,
75
+ );
76
+ }
77
+
78
+ const config: Record<string, unknown> = { ...loaded.config };
79
+ const existing = normalizePlugin(config.plugin);
80
+
81
+ // Compute the post-install plugin list: keep non-oas entries in their
82
+ // original order and append the requested specifier at the end. Comparing
83
+ // against `existing` is the canonical no-op check — if the post-install
84
+ // state equals what we already have, no write is needed.
85
+ const nonOas = existing.filter((entry) => !matchesPlugin(entry));
86
+ const dedupedNonOas = dedupePlugins(nonOas);
87
+ const finalPlugins = [...dedupedNonOas, specifier];
88
+ const isNoop = !opts.dryRun && JSON.stringify(finalPlugins) === JSON.stringify(existing);
89
+
90
+ if (isNoop) {
91
+ console.log(`✓ Already installed (${specifier}) at ${loaded.path}`);
92
+ return { status: "noop", path: loaded.path, specifier, backup: null };
93
+ }
94
+
95
+ config.plugin = finalPlugins;
96
+
97
+ if (opts.dryRun) {
98
+ console.log(`[dry-run] Would write to ${loaded.path}:`);
99
+ console.log(JSON.stringify(config, null, JSON_INDENT));
100
+ return { status: "planned", path: loaded.path, specifier, backup: null };
101
+ }
102
+
103
+ const backup = backupIfWritable(loaded.path, fs);
104
+ writeAtomically(loaded.path, JSON.stringify(config, null, JSON_INDENT), fs);
105
+
106
+ console.log(`✓ Installed ${specifier}`);
107
+ console.log(` config: ${loaded.path}`);
108
+ if (backup) console.log(` backup: ${backup}`);
109
+
110
+ return { status: "wrote", path: loaded.path, specifier, backup };
111
+ };