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 +20 -0
- package/README.md +34 -35
- package/bin/index.mjs +40 -3
- package/bin/install-skills.mjs +140 -11
- package/bin/lib/opencode-plugin.mjs +116 -0
- package/package.json +1 -1
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
|
-
|
|
31
|
-
|
|
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
|
|
71
|
-
else it installs globally into `~/.config/opencode
|
|
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
|
-
|
|
63
|
+
If you prefer to register the plugin manually instead, add it to your `opencode.json`:
|
|
74
64
|
|
|
75
|
-
```
|
|
76
|
-
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"$schema": "https://opencode.ai/config.json",
|
|
68
|
+
"plugin": ["opencode-codeops"]
|
|
69
|
+
}
|
|
77
70
|
```
|
|
78
71
|
|
|
79
|
-
Pin
|
|
72
|
+
Pin a version with `CODEOPS_VERSION` (an npm dist-tag or exact version; defaults to `latest`):
|
|
80
73
|
|
|
81
|
-
```
|
|
82
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|
package/bin/install-skills.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|