opencode-codeops 1.5.0 → 1.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to CodeOps are recorded here.
4
4
 
5
+ ## 1.7.0 — 2026-09-24
6
+
7
+ ### Fixes
8
+
9
+ - installer: install _shared and references beside the skills
10
+
11
+ ### Features
12
+
13
+ - installer: register the plugin automatically on install/update
14
+ - installer: unify install/update into one command
15
+ - installer: add npm-first install, agents installer, and release tooling
16
+
17
+ ## 1.6.0 — 2026-09-19
18
+
19
+ ### Features
20
+
21
+ - installer: register the plugin automatically on install/update
22
+ - installer: unify install/update into one command
23
+ - installer: add npm-first install, agents installer, and release tooling
24
+
5
25
  ## 1.5.0 — 2026-09-19
6
26
 
7
27
  ### Features
package/README.md CHANGED
@@ -27,29 +27,8 @@ Turn an idea or existing system into ambiguity-free requirements, grounded speci
27
27
 
28
28
  ## Installation
29
29
 
30
- CodeOps has two parts that OpenCode loads differently: the **plugin** (standards and hooks) is
31
- installed by OpenCode from npm, and the **skills** and **subagents** must be copied onto the
32
- filesystem because OpenCode only discovers those from disk.
33
-
34
- ### 1. Add the plugin
35
-
36
- Add to your `opencode.json`:
37
-
38
- ```json
39
- {
40
- "$schema": "https://opencode.ai/config.json",
41
- "plugin": ["opencode-codeops"]
42
- }
43
- ```
44
-
45
- OpenCode installs the plugin automatically via Bun on next startup. A global config
46
- (`~/.config/opencode/opencode.json`) is recommended so CodeOps is active in every project.
47
-
48
- ### 2. Install the skills and subagents
49
-
50
- OpenCode discovers skills and subagents only from the filesystem; it never reads them from a plugin
51
- package. One command installs both. The installer is a thin `npx` wrapper around this package, so
52
- the installed files always match the published version:
30
+ One command installs everything CodeOps owns: the skills, the subagents, and the OpenCode plugin
31
+ entry in your config.
53
32
 
54
33
  ```bash
55
34
  # Global (recommended) — available in every OpenCode project
@@ -66,22 +45,39 @@ npx -y opencode-codeops@latest install
66
45
  npx -y opencode-codeops@latest update # alias of install
67
46
  ```
68
47
 
48
+ `install`/`update` writes the skills and subagents onto the filesystem (OpenCode discovers those
49
+ only from disk), then registers the plugin in the OpenCode config by calling OpenCode's own
50
+ `opencode plugin` command, so standards injection and `CODEOPS_PLUGIN_ROOT` are enabled. Restart
51
+ OpenCode after installing for the plugin to load. Pass `--no-plugin` to manage the config yourself.
52
+
53
+ The installer also places the shared `_shared/` and `references/` documents beside the installed
54
+ `skills/` directory (for example `~/.config/opencode/_shared`), so the skills' relative links to
55
+ them resolve at their installed location. An existing directory with either name is only replaced
56
+ with `--force`.
57
+
69
58
  The scope is auto-detected: inside a CodeOps project (a git repo with `.opencode/` or
70
- `codeops/.codeops.yml`) it installs into `./.opencode/skills` and `./.opencode/agents`; anywhere
71
- else it installs globally into `~/.config/opencode/`. Pass `--project` or `--global` to force one.
59
+ `codeops/.codeops.yml`) it installs into `./.opencode`, and registers the plugin in the project
60
+ config; anywhere else it installs globally into `~/.config/opencode` and the global config. Pass
61
+ `--project` or `--global` to force one.
72
62
 
73
- Pin a version with `CODEOPS_VERSION` (an npm dist-tag or exact version; defaults to `latest`):
63
+ If you prefer to register the plugin manually instead, add it to your `opencode.json`:
74
64
 
75
- ```bash
76
- CODEOPS_VERSION=1.5.0 curl -fsSL https://cdn.jsdelivr.net/npm/opencode-codeops@latest/install.sh | bash
65
+ ```json
66
+ {
67
+ "$schema": "https://opencode.ai/config.json",
68
+ "plugin": ["opencode-codeops"]
69
+ }
77
70
  ```
78
71
 
79
- Pin the plugin to the same version so the two cannot drift apart:
72
+ Pin a version with `CODEOPS_VERSION` (an npm dist-tag or exact version; defaults to `latest`):
80
73
 
81
- ```json
82
- { "plugin": ["opencode-codeops@1.5.0"] }
74
+ ```bash
75
+ CODEOPS_VERSION=1.6.0 curl -fsSL https://cdn.jsdelivr.net/npm/opencode-codeops@latest/install.sh | bash
83
76
  ```
84
77
 
78
+ `install`/`update` pins the plugin in the OpenCode config to the installer's own version, so the
79
+ plugin and the installed files cannot drift apart; run `update` to move both to a new version.
80
+
85
81
  Re-running the installer upgrades an existing install in place. It replaces only the files this
86
82
  package owns, recorded in `.opencode-codeops.json`. Files you author yourself, or install with
87
83
  another tool, are left untouched.
@@ -93,21 +89,24 @@ npx -y opencode-codeops@latest status
93
89
  npx -y opencode-codeops@latest uninstall
94
90
  ```
95
91
 
96
- `status` reports the installed version next to the current package version, so a plugin/files
92
+ `status` reports the installed skills and agents versions and the configured plugin entry, so a
97
93
  mismatch is visible. Use `--dry-run` to preview an install; a same-named file the package does not
98
- own is skipped with a warning, and `--force` replaces it.
94
+ own is skipped with a warning, and `--force` replaces it. `uninstall` removes the skills and
95
+ subagents but leaves the plugin entry in your config; remove `opencode-codeops` from the `plugin`
96
+ array by hand to fully disable it.
99
97
 
100
98
  ### Local development
101
99
 
102
100
  Symlink the plugin into your OpenCode plugin directory and link the installed files to a checkout,
103
- so edits are picked up without reinstalling:
101
+ so edits are picked up without reinstalling. Pass `--no-plugin` so the checkout is not overwritten
102
+ by a registered npm plugin:
104
103
 
105
104
  ```bash
106
105
  # Plugin (project or global plugin directory)
107
106
  ln -s /path/to/opencode-codeops/plugin/index.ts ~/.config/opencode/plugins/codeops.ts
108
107
 
109
108
  # Skills and agents — link instead of copy
110
- node /path/to/opencode-codeops/bin/index.mjs install --link --global
109
+ node /path/to/opencode-codeops/bin/index.mjs install --link --global --no-plugin
111
110
  ```
112
111
 
113
112
  ## Setup
package/bin/index.mjs CHANGED
@@ -27,6 +27,8 @@ import { existsSync, realpathSync } from "node:fs"
27
27
  import { join } from "node:path"
28
28
  import { fileURLToPath } from "node:url"
29
29
 
30
+ import { PLUGIN_NAME, readConfiguredPlugin, registerPlugin } from "./lib/opencode-plugin.mjs"
31
+
30
32
  /** Commands this CLI understands. */
31
33
  const COMMANDS = new Set(["install", "update", "status", "uninstall", "help"])
32
34
 
@@ -47,10 +49,15 @@ Options:
47
49
  --dry-run Show what would happen without writing files
48
50
  --force Replace same-named files this package does not own
49
51
  --link Symlink to the source instead of copying (development)
52
+ --no-plugin Do not register the plugin in the OpenCode config
50
53
  -h, --help Show this help
51
54
 
52
55
  Scope is auto-detected: inside a CodeOps project (a git repo with .opencode/ or
53
- codeops/.codeops.yml) the files go to .opencode/; otherwise to ~/.config/opencode/.`)
56
+ codeops/.codeops.yml) the files go to .opencode/; otherwise to ~/.config/opencode/.
57
+
58
+ install/update also register the CodeOps plugin in the OpenCode config (unless
59
+ --no-plugin), so standards injection and CODEOPS_PLUGIN_ROOT are enabled. Restart
60
+ OpenCode after installing for the plugin to load.`)
54
61
  }
55
62
 
56
63
  /**
@@ -108,16 +115,46 @@ async function runCombined(command, rest, io) {
108
115
  const agents = await import("./install-agents.mjs")
109
116
 
110
117
  const cwd = io.cwd ?? process.cwd()
118
+ const noPlugin = rest.includes("--no-plugin")
119
+ const dryRun = rest.includes("--dry-run")
111
120
  const scope = resolveScope(
112
121
  { project: rest.includes("--project"), global: rest.includes("--global") },
113
122
  cwd
114
123
  )
115
- const passed = rest.filter((arg) => arg !== "--project" && arg !== "--global")
124
+ const passed = rest.filter(
125
+ (arg) => arg !== "--project" && arg !== "--global" && arg !== "--no-plugin"
126
+ )
116
127
  const scoped = [scope === "project" ? "--project" : "--global", ...passed]
117
128
 
118
129
  const skillsCode = skills.main([command, ...scoped], io)
119
130
  const agentsCode = agents.main([command, ...scoped], io)
120
- return skillsCode || agentsCode
131
+ const code = skillsCode || agentsCode
132
+
133
+ if (command === "install" && code === 0 && !noPlugin && !dryRun) {
134
+ const version = io.version ?? skills.readPackageVersion()
135
+ const result = registerPlugin({ scope, version, cwd, run: io.run })
136
+ if (result.ok) {
137
+ console.log(`Plugin: registered ${result.spec} in the ${scope} OpenCode config.`)
138
+ console.log("Restart OpenCode to load the plugin.")
139
+ } else {
140
+ console.log(
141
+ `Plugin: not registered (${result.reason}). ` +
142
+ `Add "${PLUGIN_NAME}" to the "plugin" array in your opencode.json.`
143
+ )
144
+ }
145
+ }
146
+
147
+ if (command === "status") {
148
+ const plugins = readConfiguredPlugin({ cwd, run: io.run })
149
+ if (plugins === undefined) {
150
+ console.log("plugin: opencode CLI unavailable; cannot read the config")
151
+ } else {
152
+ const entry = plugins.find((item) => String(item).startsWith(PLUGIN_NAME))
153
+ console.log(entry ? `plugin: configured (${entry})` : "plugin: not configured")
154
+ }
155
+ }
156
+
157
+ return code
121
158
  }
122
159
 
123
160
  /**
@@ -46,6 +46,18 @@ import {
46
46
  // package root that contains skills/ and package.json.
47
47
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
48
48
 
49
+ /**
50
+ * Package-root sibling directories that the installed skills reference with
51
+ * `../../<name>/...` links.
52
+ *
53
+ * The skills live in `skills/<skill>/`, so `../../_shared/...` and
54
+ * `../../references/...` only resolve when these directories sit beside the
55
+ * installed `skills` directory. The installer copies them from the parent of
56
+ * the source skills directory into the parent of the install target, and
57
+ * records them in the marker so `uninstall` can remove them.
58
+ */
59
+ export const SHARED_DIRS = ["_shared", "references"]
60
+
49
61
  export { MARKER_FILE, readMarker }
50
62
 
51
63
  /**
@@ -120,14 +132,17 @@ export function listSkills(sourceDir) {
120
132
  * @param details - Marker contents
121
133
  * @param details.version - Installed package version
122
134
  * @param details.skills - Skill directory names the package owns
135
+ * @param details.shared - Shared directory names the package owns, installed
136
+ * beside the skills directory; defaults to an empty list for older callers
123
137
  */
124
- export function writeMarker(targetDir, { version, skills }) {
138
+ export function writeMarker(targetDir, { version, skills, shared = [] }) {
125
139
  writeMarkerFile(targetDir, {
126
140
  schema: 1,
127
141
  source: "opencode-codeops",
128
142
  version,
129
143
  installedAt: new Date().toISOString(),
130
144
  skills,
145
+ shared,
131
146
  })
132
147
  }
133
148
 
@@ -159,12 +174,97 @@ function installOneSkill({ sourceDir, targetDir, name, dryRun, link }) {
159
174
  return atomicReplace({ from, targetDir, name, recursive: true })
160
175
  }
161
176
 
177
+ /**
178
+ * Installs the package-root sibling directories the skills link to.
179
+ *
180
+ * Both the source and the destination are the parents of the skills
181
+ * directories, so `../../_shared/...` and `../../references/...` links resolve
182
+ * from every installed skill. A sibling the marker does not own is left
183
+ * untouched unless `force` is set. A sibling missing from the source is skipped
184
+ * with a warning, which is normal when a custom `--source` points at a bare
185
+ * skills directory.
186
+ *
187
+ * @param details - Shared-directory install inputs
188
+ * @param details.sourceDir - Skills source directory (siblings live beside it)
189
+ * @param details.targetDir - Skills install directory (siblings install beside it)
190
+ * @param details.ownedShared - Shared directory names the marker already owns
191
+ * @param details.force - Replace unowned same-named directories
192
+ * @param details.dryRun - Report only, write nothing
193
+ * @param details.link - Symlink to the source instead of copying
194
+ * @returns The installed shared directory names and the skipped count
195
+ */
196
+ function installSharedDirs({ sourceDir, targetDir, ownedShared, force, dryRun, link }) {
197
+ const sourceRoot = dirname(sourceDir)
198
+ const installRoot = dirname(targetDir)
199
+ const shared = []
200
+ let skipped = 0
201
+
202
+ for (const name of SHARED_DIRS) {
203
+ const from = join(sourceRoot, name)
204
+ const dest = join(installRoot, name)
205
+
206
+ if (!existsSync(from)) {
207
+ // The marker may already own this directory from an earlier install even
208
+ // though the current source no longer ships it. Keep the recorded
209
+ // ownership so `uninstall` can still remove it, and leave the copy alone.
210
+ if (ownedShared.has(name) && entryExists(dest)) {
211
+ console.log(`shared directory not found in source; keeping managed copy: ${dest}`)
212
+ shared.push(name)
213
+ } else {
214
+ console.log(`shared directory not found in source, skipped: ${from}`)
215
+ }
216
+ continue
217
+ }
218
+
219
+ if (entryExists(dest) && !ownedShared.has(name) && !force) {
220
+ console.log(`conflict, skipped (not managed by opencode-codeops; use --force): ${dest}`)
221
+ skipped += 1
222
+ continue
223
+ }
224
+
225
+ const existed = entryExists(dest)
226
+ if (!dryRun) {
227
+ if (link) linkEntry({ from, dest, type: "dir" })
228
+ else atomicReplace({ from, targetDir: installRoot, name, recursive: true })
229
+ }
230
+ shared.push(name)
231
+ console.log(`${dryRun ? "[dry-run] would " : ""}${existed ? "replace" : "install"}: ${dest}`)
232
+ }
233
+
234
+ return { shared, skipped }
235
+ }
236
+
237
+ /**
238
+ * Reports that shared directories are skipped when the target is a symlink.
239
+ *
240
+ * The skills are written through a symlinked target into the real directory,
241
+ * but the shared directories would land beside the link. Their `../../` links
242
+ * then resolve beside the real skills directory, so installing beside the link
243
+ * puts them in the wrong place; copying into the link target could overwrite
244
+ * the package checkout the link often points at. Skipping leaves the escape
245
+ * hatch safe and lets the user place the shared directories beside the real
246
+ * skills directory if the link target does not already contain them.
247
+ *
248
+ * @param targetDir - Symlinked skills install directory
249
+ * @returns Empty install result
250
+ */
251
+ function skipSharedDirs(targetDir) {
252
+ console.log(
253
+ `warning: ${targetDir} is a symlink; skipping shared directories. ` +
254
+ "_shared/ and references/ must exist beside the real skills directory " +
255
+ "for the skills' relative links to resolve."
256
+ )
257
+ return { shared: [], skipped: 0 }
258
+ }
259
+
162
260
  /**
163
261
  * Installs or upgrades every packaged skill into a target directory.
164
262
  *
165
263
  * Skills named by an existing marker, plus every packaged skill on a first run,
166
264
  * are replaced. A same-named directory that the marker does not own is skipped
167
265
  * unless `force` is set, so an unrelated skill is never overwritten by accident.
266
+ * The shared directories the skills link to are installed beside the target
267
+ * through {@link installSharedDirs}.
168
268
  *
169
269
  * @param details - Install inputs
170
270
  * @param details.sourceDir - Skills directory holding the packaged skills
@@ -173,7 +273,7 @@ function installOneSkill({ sourceDir, targetDir, name, dryRun, link }) {
173
273
  * @param details.force - Replace same-named directories the marker does not own
174
274
  * @param details.dryRun - Report only, write nothing
175
275
  * @param details.link - Symlink to the source instead of copying
176
- * @returns Counts plus the skill names recorded in the marker
276
+ * @returns Counts plus the skill and shared names recorded in the marker
177
277
  */
178
278
  export function installSkills({
179
279
  sourceDir,
@@ -186,13 +286,15 @@ export function installSkills({
186
286
  const names = listSkills(sourceDir)
187
287
  const marker = readSkillsMarker(targetDir)
188
288
  const managed = marker ? new Set(marker.skills ?? []) : null
189
- const counts = { skills: names.length, installed: 0, replaced: 0, skipped: 0 }
289
+ const ownedShared = new Set(marker?.shared ?? [])
290
+ const counts = { skills: names.length, installed: 0, replaced: 0, skipped: 0, shared: 0 }
190
291
  const owned = []
191
292
  const prefix = dryRun ? "[dry-run] would " : ""
192
293
 
193
294
  if (!dryRun) {
194
295
  ensureDir(targetDir)
195
296
  cleanStaleArtifacts(targetDir)
297
+ cleanStaleArtifacts(dirname(targetDir))
196
298
  }
197
299
 
198
300
  for (const name of names) {
@@ -211,9 +313,15 @@ export function installSkills({
211
313
  console.log(`${prefix}${existed ? "replace" : "install"}: ${dest}`)
212
314
  }
213
315
 
214
- if (!dryRun) writeMarker(targetDir, { version, skills: owned })
316
+ const installedShared = isSymlink(targetDir)
317
+ ? skipSharedDirs(targetDir)
318
+ : installSharedDirs({ sourceDir, targetDir, ownedShared, force, dryRun, link })
319
+ counts.shared = installedShared.shared.length
320
+ counts.skipped += installedShared.skipped
215
321
 
216
- return { ...counts, owned }
322
+ if (!dryRun) writeMarker(targetDir, { version, skills: owned, shared: installedShared.shared })
323
+
324
+ return { ...counts, owned, shared: installedShared.shared }
217
325
  }
218
326
 
219
327
  /**
@@ -222,16 +330,19 @@ export function installSkills({
222
330
  * The marker is the ownership record, so an unmanaged install is left intact.
223
331
  * Without a marker the function reports an error instead of guessing.
224
332
  *
333
+ * Removes both the owned skills and the shared directories the installer placed
334
+ * beside them, so an uninstall leaves no package-owned files behind.
335
+ *
225
336
  * @param details - Uninstall inputs
226
337
  * @param details.targetDir - Skills directory to clean
227
338
  * @param details.dryRun - Report only, write nothing
228
- * @returns Removed skill names plus the marker outcome, or a reason it refused
339
+ * @returns Removed skill and shared names plus the marker outcome, or a reason it refused
229
340
  */
230
341
  export function uninstallSkills({ targetDir, dryRun = false }) {
231
342
  const marker = readSkillsMarker(targetDir)
232
343
 
233
344
  if (!marker) {
234
- return { removed: [], markerRemoved: false, error: "no opencode-codeops marker found" }
345
+ return { removed: [], shared: [], markerRemoved: false, error: "no opencode-codeops marker found" }
235
346
  }
236
347
 
237
348
  const removed = []
@@ -245,9 +356,21 @@ export function uninstallSkills({ targetDir, dryRun = false }) {
245
356
  console.log(`${dryRun ? "would remove" : "removed"}: ${dest}`)
246
357
  }
247
358
 
359
+ const shared = []
360
+ const installRoot = dirname(targetDir)
361
+
362
+ for (const name of marker.shared ?? []) {
363
+ const dest = join(installRoot, name)
364
+ if (!entryExists(dest)) continue
365
+
366
+ if (!dryRun) rmSync(dest, { recursive: true, force: true })
367
+ shared.push(name)
368
+ console.log(`${dryRun ? "would remove" : "removed"}: ${dest}`)
369
+ }
370
+
248
371
  if (!dryRun) rmSync(join(targetDir, MARKER_FILE), { force: true })
249
372
 
250
- return { removed, markerRemoved: true }
373
+ return { removed, shared, markerRemoved: true }
251
374
  }
252
375
 
253
376
  /** Prints command usage. */
@@ -341,6 +464,11 @@ function printStatus({ targetDir, sourceDir, sourceVersion }) {
341
464
  if (missing.length > 0) {
342
465
  console.log(`warning: missing managed skills: ${missing.join(", ")}`)
343
466
  }
467
+ const installRoot = dirname(targetDir)
468
+ const missingShared = (marker.shared ?? []).filter((name) => !entryExists(join(installRoot, name)))
469
+ if (missingShared.length > 0) {
470
+ console.log(`warning: missing managed shared directories: ${missingShared.join(", ")}`)
471
+ }
344
472
  return
345
473
  }
346
474
 
@@ -415,8 +543,8 @@ export function main(argv, io = {}) {
415
543
  return 0
416
544
  }
417
545
  console.log(
418
- `${options.dryRun ? "would remove" : "removed"} ${result.removed.length} skill(s); ` +
419
- `marker ${options.dryRun ? "would be removed" : "removed"}`
546
+ `${options.dryRun ? "would remove" : "removed"} ${result.removed.length} skill(s) and ` +
547
+ `${result.shared.length} shared dir(s); marker ${options.dryRun ? "would be removed" : "removed"}`
420
548
  )
421
549
  return 0
422
550
  }
@@ -432,7 +560,8 @@ export function main(argv, io = {}) {
432
560
  const mode = options.dryRun ? " (dry-run, nothing written)" : ""
433
561
  console.log(
434
562
  `Done${mode}: ${counts.skills} skills | ` +
435
- `installed ${counts.installed}, replaced ${counts.replaced}, ${counts.skipped} skipped`
563
+ `installed ${counts.installed}, replaced ${counts.replaced}, ${counts.skipped} skipped | ` +
564
+ `shared ${counts.shared}/${SHARED_DIRS.length}`
436
565
  )
437
566
  if (counts.skipped > 0) {
438
567
  console.log("Re-run with --force to replace skipped directories.")
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Registers (or inspects) the CodeOps plugin in OpenCode's own config.
4
+ *
5
+ * OpenCode ships a command that installs a plugin and updates the config
6
+ * safely, including JSON/JSONC formatting and version replacement:
7
+ *
8
+ * opencode plugin <module> [--global] [--force]
9
+ *
10
+ * The installer delegates to that command instead of editing `opencode.json`
11
+ * itself, so it never has to parse JSONC or risk clobbering a user's config.
12
+ * All calls are best-effort: if the `opencode` executable is missing or the
13
+ * command fails, the caller still completes the file install and tells the user
14
+ * how to register the plugin manually.
15
+ *
16
+ * @module lib/opencode-plugin
17
+ */
18
+
19
+ import { spawnSync } from "node:child_process"
20
+
21
+ /** The npm package name of the plugin. */
22
+ export const PLUGIN_NAME = "opencode-codeops"
23
+
24
+ /**
25
+ * Runs a command and normalizes the result.
26
+ *
27
+ * @param command - Executable to run
28
+ * @param args - Argument list
29
+ * @param options - Spawn options (for example `cwd`)
30
+ * @returns The exit status, stdout, stderr, and any spawn error
31
+ */
32
+ export function defaultRun(command, args, options = {}) {
33
+ const result = spawnSync(command, args, { encoding: "utf-8", ...options })
34
+ return {
35
+ status: result.status ?? 1,
36
+ stdout: result.stdout ?? "",
37
+ stderr: result.stderr ?? "",
38
+ error: result.error,
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Builds the arguments for `opencode plugin`.
44
+ *
45
+ * @param details - Plugin registration inputs
46
+ * @param details.scope - `"global"` adds `--global`; `"project"` omits it
47
+ * @param details.version - Exact version to pin, or `undefined` for the bare name
48
+ * @returns Arguments after the `opencode` executable
49
+ * @example
50
+ * buildPluginArgs({ scope: "global", version: "1.0.0" })
51
+ * // ["plugin", "opencode-codeops@1.0.0", "--global", "--force"]
52
+ */
53
+ export function buildPluginArgs({ scope, version }) {
54
+ const module = version ? `${PLUGIN_NAME}@${version}` : PLUGIN_NAME
55
+ const args = ["plugin", module]
56
+ if (scope === "global") args.push("--global")
57
+ args.push("--force")
58
+ return args
59
+ }
60
+
61
+ /**
62
+ * Registers the plugin in the OpenCode config.
63
+ *
64
+ * Tries the exact version first so plugin and files stay in sync; if the CLI
65
+ * rejects the versioned spec, falls back to the bare package name. Never throws.
66
+ *
67
+ * @param details - Registration inputs
68
+ * @param details.scope - `"global"` or `"project"`
69
+ * @param details.version - Version to pin
70
+ * @param details.cwd - Working directory (used for project scope)
71
+ * @param details.run - Command runner, injectable for tests
72
+ * @returns Whether registration succeeded, the spec used, and a reason on failure
73
+ */
74
+ export function registerPlugin({ scope, version, cwd, run = defaultRun }) {
75
+ try {
76
+ const probe = run("opencode", ["--version"], { cwd })
77
+ if (probe.error || probe.status !== 0) {
78
+ return { ok: false, spec: null, reason: "opencode CLI not found on PATH" }
79
+ }
80
+
81
+ const pinned = run("opencode", buildPluginArgs({ scope, version }), { cwd })
82
+ if (!pinned.error && pinned.status === 0) {
83
+ return { ok: true, spec: `${PLUGIN_NAME}@${version}` }
84
+ }
85
+
86
+ const bare = run("opencode", buildPluginArgs({ scope, version: null }), { cwd })
87
+ if (!bare.error && bare.status === 0) {
88
+ return { ok: true, spec: PLUGIN_NAME }
89
+ }
90
+
91
+ const message = (bare.stderr || bare.stdout || pinned.stderr || pinned.stdout || "").trim()
92
+ return { ok: false, spec: null, reason: message.split("\n").pop() || "opencode plugin failed" }
93
+ } catch (caught) {
94
+ return { ok: false, spec: null, reason: caught.message }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Reads the resolved `plugin` list from OpenCode's effective config.
100
+ *
101
+ * @param details - Inspection inputs
102
+ * @param details.cwd - Working directory
103
+ * @param details.run - Command runner, injectable for tests
104
+ * @returns The plugin entries, or `undefined` when the config cannot be read
105
+ */
106
+ export function readConfiguredPlugin({ cwd, run = defaultRun } = {}) {
107
+ try {
108
+ const result = run("opencode", ["debug", "config"], { cwd })
109
+ if (result.error || result.status !== 0) return undefined
110
+
111
+ const config = JSON.parse(result.stdout)
112
+ return Array.isArray(config.plugin) ? config.plugin : []
113
+ } catch {
114
+ return undefined
115
+ }
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codeops",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Specification-first engineering for complex systems — CodeOps plugin for OpenCode",
5
5
  "type": "module",
6
6
  "engines": {