opencode-codeops 1.4.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 +179 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/_shared/auto-design.md +129 -0
- package/_shared/layout-convention.md +198 -0
- package/_shared/quality-profile.md +134 -0
- package/_shared/recommendation-hardening.md +166 -0
- package/_shared/scope-expansion-control.md +176 -0
- package/_shared/spec-first-ordering.md +79 -0
- package/_shared/zero-ambiguity-gate.md +311 -0
- package/agent-templates/codebase-scout.md +17 -0
- package/agent-templates/concurrency-auditor.md +5 -0
- package/agent-templates/design-challenger.md +26 -0
- package/agent-templates/financial-integrity-auditor.md +5 -0
- package/agent-templates/perf-auditor.md +23 -0
- package/agent-templates/phase-reviewer.md +54 -0
- package/agent-templates/plan-task-executor-opus.md +46 -0
- package/agent-templates/plan-task-executor.md +43 -0
- package/agent-templates/preflight-auditor.md +45 -0
- package/agent-templates/security-auditor.md +42 -0
- package/agent-templates/semantics-reviewer.md +5 -0
- package/agent-templates/spec-test-author.md +29 -0
- package/agents/concurrency-auditor.md +15 -0
- package/agents/correctness-reviewer.md +66 -0
- package/agents/demanding-executor.md +58 -0
- package/agents/design-challenger.md +38 -0
- package/agents/executor.md +55 -0
- package/agents/explorer.md +29 -0
- package/agents/financial-integrity-auditor.md +15 -0
- package/agents/performance-auditor.md +35 -0
- package/agents/preflight-auditor.md +57 -0
- package/agents/security-auditor.md +54 -0
- package/agents/semantics-reviewer.md +15 -0
- package/agents/spec-test-author.md +41 -0
- package/bin/codeops-worktree +244 -0
- package/bin/index.mjs +106 -0
- package/bin/install-agents.mjs +453 -0
- package/bin/install-skills.mjs +466 -0
- package/bin/lib/opencode-install.mjs +185 -0
- package/install.sh +55 -0
- package/package.json +73 -0
- package/plugin/index.ts +181 -0
- package/references/domains/compiler-and-language.md +28 -0
- package/references/domains/data-and-migration.md +22 -0
- package/references/domains/distributed-and-concurrent.md +26 -0
- package/references/domains/financial-system.md +28 -0
- package/references/domains/selection.md +19 -0
- package/references/domains/web-application.md +23 -0
- package/schemas/codeops-config.schema.json +56 -0
- package/scripts/check-version.mjs +163 -0
- package/scripts/codeops-migrate.sh +355 -0
- package/scripts/codeops-roadmap-compact.sh +232 -0
- package/scripts/codeops-roadmap-sync.sh +275 -0
- package/scripts/codeops_outcomes.py +155 -0
- package/scripts/codeops_plan.py +239 -0
- package/scripts/codeops_plan_migrate.py +318 -0
- package/scripts/codeops_worktree_snapshot.py +99 -0
- package/scripts/install_agents.py +288 -0
- package/scripts/release.mjs +533 -0
- package/skills/analyze-project/SKILL.md +28 -0
- package/skills/clean-comments/SKILL.md +22 -0
- package/skills/exec-plan/SKILL.md +267 -0
- package/skills/exec-plan/commit-modes.md +113 -0
- package/skills/exec-plan/execution-protocol.md +471 -0
- package/skills/git-commit/SKILL.md +35 -0
- package/skills/github-issues/SKILL.md +38 -0
- package/skills/grill-me/SKILL.md +342 -0
- package/skills/make-plan/SKILL.md +282 -0
- package/skills/make-plan/quality-checklist.md +96 -0
- package/skills/make-plan/templates.md +535 -0
- package/skills/make-plan/zero-ambiguity-gate.md +19 -0
- package/skills/make-requirements/SKILL.md +268 -0
- package/skills/make-requirements/discovery-phases.md +255 -0
- package/skills/make-requirements/review-and-add.md +73 -0
- package/skills/make-requirements/templates.md +296 -0
- package/skills/make-requirements/zero-ambiguity-gate.md +18 -0
- package/skills/outcome-review/SKILL.md +34 -0
- package/skills/preflight/SKILL.md +310 -0
- package/skills/preflight/dimensions.md +181 -0
- package/skills/preflight/report-format.md +300 -0
- package/skills/retro-requirements/SKILL.md +218 -0
- package/skills/retro-requirements/confidence-classification.md +45 -0
- package/skills/retro-requirements/phases.md +609 -0
- package/skills/retro-requirements/triage-gate.md +135 -0
- package/skills/roadmap/SKILL.md +381 -0
- package/skills/roadmap/stage-hooks.md +80 -0
- package/skills/roadmap/template.md +200 -0
- package/skills/setup-codeops/SKILL.md +94 -0
- package/skills/setup-codeops/migration.md +106 -0
- package/skills/setup-codeops/scaffold.md +99 -0
- package/skills/setup-routing/SKILL.md +102 -0
- package/skills/setup-routing/routing.md +44 -0
- package/skills/techdocs/SKILL.md +199 -0
- package/skills/techdocs/authoring-and-update.md +178 -0
- package/skills/techdocs/templates.md +655 -0
- package/skills/techdocs/vitepress-setup.md +143 -0
- package/skills/upgrade-plan/SKILL.md +75 -0
- package/skills/upgrade-plan/content-quality-gate.md +35 -0
- package/skills/upgrade-plan/upgrade-checklists.md +107 -0
- package/standards/coding-standards-full.md +124 -0
- package/standards/coding-standards.md +64 -0
- package/standards/output-style.md +17 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Install, inspect, and remove the CodeOps skills for OpenCode.
|
|
4
|
+
*
|
|
5
|
+
* OpenCode discovers skills only from the filesystem: `.opencode/skills/` in a
|
|
6
|
+
* project, or `~/.config/opencode/skills/` globally. It never reads them from a
|
|
7
|
+
* plugin package. This script copies every shipped skill directory (SKILL.md
|
|
8
|
+
* plus its supporting files) into one of those locations.
|
|
9
|
+
*
|
|
10
|
+
* Ownership rules keep the installer safe to run repeatedly:
|
|
11
|
+
* - `install` replaces the skills this package owns, so re-running upgrades an
|
|
12
|
+
* existing install in place.
|
|
13
|
+
* - A marker file records which skill directories the package owns. Skills the
|
|
14
|
+
* marker does not name are never touched, so user-authored skills and skills
|
|
15
|
+
* installed by other tools survive.
|
|
16
|
+
* - Each skill is replaced atomically: a temporary copy is built first, the old
|
|
17
|
+
* directory is set aside, and only then is the new copy moved into place. If
|
|
18
|
+
* anything fails, the previous directory is restored.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* opencode-codeops install-skills [options] Install or upgrade (default)
|
|
22
|
+
* opencode-codeops status [options] Show the installed version
|
|
23
|
+
* opencode-codeops uninstall [options] Remove the managed skills
|
|
24
|
+
*
|
|
25
|
+
* @module install-skills
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs"
|
|
29
|
+
import { dirname, join, resolve } from "node:path"
|
|
30
|
+
import { fileURLToPath } from "node:url"
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
MARKER_FILE,
|
|
34
|
+
atomicReplace,
|
|
35
|
+
cleanStaleArtifacts,
|
|
36
|
+
ensureDir,
|
|
37
|
+
entryExists,
|
|
38
|
+
isSymlink,
|
|
39
|
+
linkEntry,
|
|
40
|
+
readMarker,
|
|
41
|
+
resolveTarget,
|
|
42
|
+
writeMarkerFile,
|
|
43
|
+
} from "./lib/opencode-install.mjs"
|
|
44
|
+
|
|
45
|
+
// This file lives in <package root>/bin/, so the parent of its directory is the
|
|
46
|
+
// package root that contains skills/ and package.json.
|
|
47
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
48
|
+
|
|
49
|
+
export { MARKER_FILE, readMarker }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads the marker only when it is a skills marker.
|
|
53
|
+
*
|
|
54
|
+
* Both installers use the same marker file name, so a marker written by the
|
|
55
|
+
* agent installer must not be mistaken for one that lists skills. Treating it
|
|
56
|
+
* as absent is safe: it makes the install behave like a first run instead of
|
|
57
|
+
* silently skipping every packaged skill.
|
|
58
|
+
*
|
|
59
|
+
* @param targetDir - Skills directory that may hold a marker
|
|
60
|
+
* @returns The parsed skills marker, or `undefined`
|
|
61
|
+
*/
|
|
62
|
+
function readSkillsMarker(targetDir) {
|
|
63
|
+
const marker = readMarker(targetDir)
|
|
64
|
+
return marker && Array.isArray(marker.skills) ? marker : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads this package's own version, recorded in the install marker.
|
|
69
|
+
*
|
|
70
|
+
* @returns The package version, or `"0.0.0"` when package.json cannot be read
|
|
71
|
+
*/
|
|
72
|
+
export function readPackageVersion() {
|
|
73
|
+
try {
|
|
74
|
+
const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"))
|
|
75
|
+
return typeof manifest.version === "string" ? manifest.version : "0.0.0"
|
|
76
|
+
} catch {
|
|
77
|
+
return "0.0.0"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolves the directory that holds the shipped skills.
|
|
83
|
+
*
|
|
84
|
+
* The `CODEOPS_PLUGIN_ROOT` environment variable overrides the default. That
|
|
85
|
+
* lets a checkout run the installer before the package is published.
|
|
86
|
+
*
|
|
87
|
+
* @param override - Optional explicit skills directory
|
|
88
|
+
* @returns Absolute path to the skills directory
|
|
89
|
+
*/
|
|
90
|
+
export function resolveSourceDir(override) {
|
|
91
|
+
if (override) return resolve(override)
|
|
92
|
+
|
|
93
|
+
const envRoot = process.env.CODEOPS_PLUGIN_ROOT
|
|
94
|
+
if (envRoot && existsSync(join(envRoot, "skills"))) {
|
|
95
|
+
return join(envRoot, "skills")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return join(PACKAGE_ROOT, "skills")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Lists the skill names shipped in a skills directory.
|
|
103
|
+
*
|
|
104
|
+
* A directory counts as a skill when it contains `SKILL.md`. Symbolic links are
|
|
105
|
+
* skipped, so a development link to an external skill tree is not packaged.
|
|
106
|
+
*
|
|
107
|
+
* @param sourceDir - Directory to scan
|
|
108
|
+
* @returns Sorted skill directory names
|
|
109
|
+
*/
|
|
110
|
+
export function listSkills(sourceDir) {
|
|
111
|
+
if (!existsSync(sourceDir)) return []
|
|
112
|
+
|
|
113
|
+
return readdirSync(sourceDir, { withFileTypes: true })
|
|
114
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(sourceDir, entry.name, "SKILL.md")))
|
|
115
|
+
.map((entry) => entry.name)
|
|
116
|
+
.sort()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Writes the install marker that records ownership and the installed version.
|
|
121
|
+
*
|
|
122
|
+
* @param targetDir - Skills directory to mark
|
|
123
|
+
* @param details - Marker contents
|
|
124
|
+
* @param details.version - Installed package version
|
|
125
|
+
* @param details.skills - Skill directory names the package owns
|
|
126
|
+
*/
|
|
127
|
+
export function writeMarker(targetDir, { version, skills }) {
|
|
128
|
+
writeMarkerFile(targetDir, {
|
|
129
|
+
schema: 1,
|
|
130
|
+
source: "opencode-codeops",
|
|
131
|
+
version,
|
|
132
|
+
installedAt: new Date().toISOString(),
|
|
133
|
+
skills,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Installs one skill by replacing its directory atomically, or by linking to it
|
|
139
|
+
* when `link` is set (used by development checkouts).
|
|
140
|
+
*
|
|
141
|
+
* @param details - Install inputs
|
|
142
|
+
* @param details.sourceDir - Skills directory holding the packaged skill
|
|
143
|
+
* @param details.targetDir - Skills directory to install into
|
|
144
|
+
* @param details.name - Skill directory name
|
|
145
|
+
* @param details.dryRun - Report only, write nothing
|
|
146
|
+
* @param details.link - Symlink to the source instead of copying
|
|
147
|
+
* @returns Whether a previous directory existed
|
|
148
|
+
*/
|
|
149
|
+
function installOneSkill({ sourceDir, targetDir, name, dryRun, link }) {
|
|
150
|
+
const from = join(sourceDir, name)
|
|
151
|
+
const dest = join(targetDir, name)
|
|
152
|
+
const existed = entryExists(dest)
|
|
153
|
+
|
|
154
|
+
if (dryRun) return { existed }
|
|
155
|
+
|
|
156
|
+
if (link) {
|
|
157
|
+
ensureDir(targetDir)
|
|
158
|
+
linkEntry({ from, dest, type: "dir" })
|
|
159
|
+
return { existed }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return atomicReplace({ from, targetDir, name, recursive: true })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Installs or upgrades every packaged skill into a target directory.
|
|
167
|
+
*
|
|
168
|
+
* Skills named by an existing marker, plus every packaged skill on a first run,
|
|
169
|
+
* are replaced. A same-named directory that the marker does not own is skipped
|
|
170
|
+
* unless `force` is set, so an unrelated skill is never overwritten by accident.
|
|
171
|
+
*
|
|
172
|
+
* @param details - Install inputs
|
|
173
|
+
* @param details.sourceDir - Skills directory holding the packaged skills
|
|
174
|
+
* @param details.targetDir - Skills directory to install into
|
|
175
|
+
* @param details.version - Version stored in the marker
|
|
176
|
+
* @param details.force - Replace same-named directories the marker does not own
|
|
177
|
+
* @param details.dryRun - Report only, write nothing
|
|
178
|
+
* @param details.link - Symlink to the source instead of copying
|
|
179
|
+
* @returns Counts plus the skill names recorded in the marker
|
|
180
|
+
*/
|
|
181
|
+
export function installSkills({
|
|
182
|
+
sourceDir,
|
|
183
|
+
targetDir,
|
|
184
|
+
version,
|
|
185
|
+
force = false,
|
|
186
|
+
dryRun = false,
|
|
187
|
+
link = false,
|
|
188
|
+
}) {
|
|
189
|
+
const names = listSkills(sourceDir)
|
|
190
|
+
const marker = readSkillsMarker(targetDir)
|
|
191
|
+
const managed = marker ? new Set(marker.skills ?? []) : null
|
|
192
|
+
const counts = { skills: names.length, installed: 0, replaced: 0, skipped: 0 }
|
|
193
|
+
const owned = []
|
|
194
|
+
const prefix = dryRun ? "[dry-run] would " : ""
|
|
195
|
+
|
|
196
|
+
if (!dryRun) {
|
|
197
|
+
ensureDir(targetDir)
|
|
198
|
+
cleanStaleArtifacts(targetDir)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const name of names) {
|
|
202
|
+
const dest = join(targetDir, name)
|
|
203
|
+
|
|
204
|
+
if (entryExists(dest) && managed && !managed.has(name) && !force) {
|
|
205
|
+
console.log(`conflict, skipped (not managed by opencode-codeops; use --force): ${dest}`)
|
|
206
|
+
counts.skipped += 1
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { existed } = installOneSkill({ sourceDir, targetDir, name, dryRun, link })
|
|
211
|
+
owned.push(name)
|
|
212
|
+
if (existed) counts.replaced += 1
|
|
213
|
+
else counts.installed += 1
|
|
214
|
+
console.log(`${prefix}${existed ? "replace" : "install"}: ${dest}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!dryRun) writeMarker(targetDir, { version, skills: owned })
|
|
218
|
+
|
|
219
|
+
return { ...counts, owned }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Removes only the skills recorded as owned by this package.
|
|
224
|
+
*
|
|
225
|
+
* The marker is the ownership record, so an unmanaged install is left intact.
|
|
226
|
+
* Without a marker the function reports an error instead of guessing.
|
|
227
|
+
*
|
|
228
|
+
* @param details - Uninstall inputs
|
|
229
|
+
* @param details.targetDir - Skills directory to clean
|
|
230
|
+
* @param details.dryRun - Report only, write nothing
|
|
231
|
+
* @returns Removed skill names plus the marker outcome, or a reason it refused
|
|
232
|
+
*/
|
|
233
|
+
export function uninstallSkills({ targetDir, dryRun = false }) {
|
|
234
|
+
const marker = readSkillsMarker(targetDir)
|
|
235
|
+
|
|
236
|
+
if (!marker) {
|
|
237
|
+
return { removed: [], markerRemoved: false, error: "no opencode-codeops marker found" }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const removed = []
|
|
241
|
+
|
|
242
|
+
for (const name of marker.skills ?? []) {
|
|
243
|
+
const dest = join(targetDir, name)
|
|
244
|
+
if (!entryExists(dest)) continue
|
|
245
|
+
|
|
246
|
+
if (!dryRun) rmSync(dest, { recursive: true, force: true })
|
|
247
|
+
removed.push(name)
|
|
248
|
+
console.log(`${dryRun ? "would remove" : "removed"}: ${dest}`)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!dryRun) rmSync(join(targetDir, MARKER_FILE), { force: true })
|
|
252
|
+
|
|
253
|
+
return { removed, markerRemoved: true }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Prints command usage. */
|
|
257
|
+
function printUsage() {
|
|
258
|
+
console.log(`Install the CodeOps skills so OpenCode can discover them.
|
|
259
|
+
|
|
260
|
+
Usage:
|
|
261
|
+
opencode-codeops install-skills [options] Install or upgrade (default)
|
|
262
|
+
opencode-codeops status [options] Show the installed version
|
|
263
|
+
opencode-codeops uninstall [options] Remove the managed skills
|
|
264
|
+
|
|
265
|
+
Options:
|
|
266
|
+
--global Use ~/.config/opencode/skills (default)
|
|
267
|
+
--project Use ./.opencode/skills
|
|
268
|
+
--target <dir> Use a custom skills directory
|
|
269
|
+
--source <dir> Override the packaged skills directory
|
|
270
|
+
--link Symlink to the source instead of copying (development)
|
|
271
|
+
--dry-run Show what would happen without writing files
|
|
272
|
+
--force Replace same-named directories this package does not own
|
|
273
|
+
-h, --help Show this help`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Parses argv into a subcommand and options.
|
|
278
|
+
*
|
|
279
|
+
* The subcommand is optional and defaults to `install`. `install-skills` is
|
|
280
|
+
* accepted as an alias for `install` so the documented `npx` invocation keeps
|
|
281
|
+
* working.
|
|
282
|
+
*
|
|
283
|
+
* @param argv - Arguments after the executable
|
|
284
|
+
* @returns The command, parsed options, and an error message when invalid
|
|
285
|
+
*/
|
|
286
|
+
export function parseArgs(argv) {
|
|
287
|
+
const options = {
|
|
288
|
+
project: false,
|
|
289
|
+
target: null,
|
|
290
|
+
source: null,
|
|
291
|
+
dryRun: false,
|
|
292
|
+
force: false,
|
|
293
|
+
link: false,
|
|
294
|
+
help: false,
|
|
295
|
+
}
|
|
296
|
+
let command = "install"
|
|
297
|
+
let index = 0
|
|
298
|
+
|
|
299
|
+
const first = argv[0]
|
|
300
|
+
if (first && !first.startsWith("-")) {
|
|
301
|
+
if (first === "install" || first === "install-skills") command = "install"
|
|
302
|
+
else if (first === "status" || first === "uninstall") command = first
|
|
303
|
+
else if (first === "help") command = "help"
|
|
304
|
+
else return { command, options, error: `unknown command '${first}'` }
|
|
305
|
+
index = 1
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (; index < argv.length; index += 1) {
|
|
309
|
+
const arg = argv[index]
|
|
310
|
+
|
|
311
|
+
if (arg === "--global") options.project = false
|
|
312
|
+
else if (arg === "--project") options.project = true
|
|
313
|
+
else if (arg === "--dry-run") options.dryRun = true
|
|
314
|
+
else if (arg === "--force") options.force = true
|
|
315
|
+
else if (arg === "--link") options.link = true
|
|
316
|
+
else if (arg === "--help" || arg === "-h") options.help = true
|
|
317
|
+
else if (arg === "--target" || arg === "--source") {
|
|
318
|
+
const value = argv[index + 1]
|
|
319
|
+
if (!value || value.startsWith("--")) {
|
|
320
|
+
return { command, options, error: `${arg} requires a directory argument` }
|
|
321
|
+
}
|
|
322
|
+
if (arg === "--target") options.target = value
|
|
323
|
+
else options.source = value
|
|
324
|
+
index += 1
|
|
325
|
+
} else {
|
|
326
|
+
return { command, options, error: `unknown argument '${arg}'` }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { command, options }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Prints the installed version and health for one target directory. */
|
|
334
|
+
function printStatus({ targetDir, sourceDir, sourceVersion }) {
|
|
335
|
+
const marker = readSkillsMarker(targetDir)
|
|
336
|
+
|
|
337
|
+
if (marker) {
|
|
338
|
+
const owned = marker.skills ?? []
|
|
339
|
+
console.log(`installed: v${marker.version} (${owned.length} skills)`)
|
|
340
|
+
if (sourceVersion && marker.version !== sourceVersion) {
|
|
341
|
+
console.log(`update available: v${sourceVersion} — run install-skills to upgrade`)
|
|
342
|
+
}
|
|
343
|
+
const missing = owned.filter((name) => !entryExists(join(targetDir, name)))
|
|
344
|
+
if (missing.length > 0) {
|
|
345
|
+
console.log(`warning: missing managed skills: ${missing.join(", ")}`)
|
|
346
|
+
}
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const present = listSkills(sourceDir).filter((name) => entryExists(join(targetDir, name)))
|
|
351
|
+
if (present.length > 0) {
|
|
352
|
+
console.log(`not managed by opencode-codeops (found ${present.length} packaged skills, no marker)`)
|
|
353
|
+
} else {
|
|
354
|
+
console.log("not installed")
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Runs the installer CLI.
|
|
360
|
+
*
|
|
361
|
+
* @param argv - Arguments after the executable
|
|
362
|
+
* @param io - Injectable environment for tests
|
|
363
|
+
* @param io.cwd - Project root used for `--project`
|
|
364
|
+
* @param io.source - Override the packaged skills directory
|
|
365
|
+
* @param io.version - Override the version recorded in the marker
|
|
366
|
+
* @returns Process exit code
|
|
367
|
+
*/
|
|
368
|
+
export function main(argv, io = {}) {
|
|
369
|
+
if (argv.length === 0) {
|
|
370
|
+
printUsage()
|
|
371
|
+
return 0
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const { command, options, error } = parseArgs(argv)
|
|
375
|
+
if (error) {
|
|
376
|
+
console.error(`error: ${error}`)
|
|
377
|
+
return 2
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (options.help || command === "help") {
|
|
381
|
+
printUsage()
|
|
382
|
+
return 0
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const cwd = io.cwd ?? process.cwd()
|
|
386
|
+
const version = io.version ?? readPackageVersion()
|
|
387
|
+
const sourceDir = io.source ?? resolveSourceDir(options.source)
|
|
388
|
+
const targetDir = resolveTarget(options, cwd, "skills")
|
|
389
|
+
|
|
390
|
+
if (!existsSync(sourceDir)) {
|
|
391
|
+
console.error(`error: skills directory not found: ${sourceDir}`)
|
|
392
|
+
return 1
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log(`Source: ${sourceDir}`)
|
|
396
|
+
console.log(`Target: ${targetDir}`)
|
|
397
|
+
|
|
398
|
+
// Writing through a symlinked target would mutate whatever the link points
|
|
399
|
+
// at, which is often the package's own source tree. Refuse unless the user
|
|
400
|
+
// explicitly overrides with --force. Status is read-only and always allowed.
|
|
401
|
+
if (command !== "status" && !options.force && isSymlink(targetDir)) {
|
|
402
|
+
console.error(
|
|
403
|
+
`error: target skills directory is a symlink to ${realpathSync(targetDir)}; ` +
|
|
404
|
+
"install/uninstall would write through it. Pass --force to proceed."
|
|
405
|
+
)
|
|
406
|
+
return 2
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (command === "status") {
|
|
410
|
+
printStatus({ targetDir, sourceDir, sourceVersion: version })
|
|
411
|
+
return 0
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (command === "uninstall") {
|
|
415
|
+
const result = uninstallSkills({ targetDir, dryRun: options.dryRun })
|
|
416
|
+
if (result.error) {
|
|
417
|
+
console.error(`error: ${result.error} at ${targetDir}`)
|
|
418
|
+
return 1
|
|
419
|
+
}
|
|
420
|
+
console.log(
|
|
421
|
+
`${options.dryRun ? "would remove" : "removed"} ${result.removed.length} skill(s); ` +
|
|
422
|
+
`marker ${options.dryRun ? "would be removed" : "removed"}`
|
|
423
|
+
)
|
|
424
|
+
return 0
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const counts = installSkills({
|
|
428
|
+
sourceDir,
|
|
429
|
+
targetDir,
|
|
430
|
+
version,
|
|
431
|
+
force: options.force,
|
|
432
|
+
dryRun: options.dryRun,
|
|
433
|
+
link: options.link,
|
|
434
|
+
})
|
|
435
|
+
const mode = options.dryRun ? " (dry-run, nothing written)" : ""
|
|
436
|
+
console.log(
|
|
437
|
+
`Done${mode}: ${counts.skills} skills | ` +
|
|
438
|
+
`installed ${counts.installed}, replaced ${counts.replaced}, ${counts.skipped} skipped`
|
|
439
|
+
)
|
|
440
|
+
if (counts.skipped > 0) {
|
|
441
|
+
console.log("Re-run with --force to replace skipped directories.")
|
|
442
|
+
}
|
|
443
|
+
return 0
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* True when this module is the process entry point.
|
|
448
|
+
*
|
|
449
|
+
* The comparison resolves symlinks because npm installs the bin as a symlink in
|
|
450
|
+
* `node_modules/.bin`, so `process.argv[1]` is the link path, not the real path.
|
|
451
|
+
*
|
|
452
|
+
* @returns True when this file is the entry point
|
|
453
|
+
*/
|
|
454
|
+
function isMainModule() {
|
|
455
|
+
if (!process.argv[1]) return false
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1])
|
|
459
|
+
} catch {
|
|
460
|
+
return false
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (isMainModule()) {
|
|
465
|
+
process.exitCode = main(process.argv.slice(2))
|
|
466
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Shared filesystem helpers for the CodeOps installers.
|
|
4
|
+
*
|
|
5
|
+
* Both the skills installer (`install-skills.mjs`) and the agent installer
|
|
6
|
+
* (`install-agents.mjs`) copy packaged files into an OpenCode discovery
|
|
7
|
+
* directory, record what they own in a marker, and replace only what they own.
|
|
8
|
+
* This module holds the parts of that work that are identical for both, so the
|
|
9
|
+
* ownership and atomic-replace rules live in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* @module lib/opencode-install
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
cpSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
symlinkSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs"
|
|
26
|
+
import { homedir } from "node:os"
|
|
27
|
+
import { join, resolve } from "node:path"
|
|
28
|
+
|
|
29
|
+
/** Name of the marker file that records a managed install. */
|
|
30
|
+
export const MARKER_FILE = ".opencode-codeops.json"
|
|
31
|
+
|
|
32
|
+
// Working directories used during an atomic replace. They are namespaced by
|
|
33
|
+
// process id so two concurrent installs cannot collide, and any that survive a
|
|
34
|
+
// crash are removed at the start of the next run.
|
|
35
|
+
const TEMP_PREFIX = ".codeops.tmp-"
|
|
36
|
+
const OLD_PREFIX = ".codeops.old-"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tests whether a path exists as any entry type, including a dangling symlink.
|
|
40
|
+
*
|
|
41
|
+
* `fs.existsSync` follows symlinks and reports a broken link as absent, which
|
|
42
|
+
* would make an installer try to move a file over an existing link.
|
|
43
|
+
* `fs.lstatSync` inspects the link itself.
|
|
44
|
+
*
|
|
45
|
+
* @param targetPath - Path to test
|
|
46
|
+
* @returns True when an entry exists at the path
|
|
47
|
+
*/
|
|
48
|
+
export function entryExists(targetPath) {
|
|
49
|
+
try {
|
|
50
|
+
lstatSync(targetPath)
|
|
51
|
+
return true
|
|
52
|
+
} catch {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tests whether a path is a symbolic link, without following it.
|
|
59
|
+
*
|
|
60
|
+
* @param targetPath - Path to test
|
|
61
|
+
* @returns True when the path itself is a symlink
|
|
62
|
+
*/
|
|
63
|
+
export function isSymlink(targetPath) {
|
|
64
|
+
try {
|
|
65
|
+
return lstatSync(targetPath).isSymbolicLink()
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Removes temporary directories left behind by an interrupted earlier run.
|
|
73
|
+
*
|
|
74
|
+
* @param targetDir - Directory to clean
|
|
75
|
+
*/
|
|
76
|
+
export function cleanStaleArtifacts(targetDir) {
|
|
77
|
+
for (const entry of readdirSync(targetDir)) {
|
|
78
|
+
if (entry.startsWith(TEMP_PREFIX) || entry.startsWith(OLD_PREFIX)) {
|
|
79
|
+
rmSync(join(targetDir, entry), { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reads the install marker from a target directory.
|
|
86
|
+
*
|
|
87
|
+
* @param targetDir - Directory that may hold a marker
|
|
88
|
+
* @returns The parsed marker, or `undefined` when none exists or it is invalid
|
|
89
|
+
*/
|
|
90
|
+
export function readMarker(targetDir) {
|
|
91
|
+
const markerPath = join(targetDir, MARKER_FILE)
|
|
92
|
+
if (!entryExists(markerPath)) return undefined
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(markerPath, "utf-8"))
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Writes a marker object to a target directory as pretty-printed JSON.
|
|
103
|
+
*
|
|
104
|
+
* @param targetDir - Directory to mark
|
|
105
|
+
* @param marker - Marker contents to serialize
|
|
106
|
+
*/
|
|
107
|
+
export function writeMarkerFile(targetDir, marker) {
|
|
108
|
+
writeFileSync(join(targetDir, MARKER_FILE), `${JSON.stringify(marker, null, 2)}\n`, "utf-8")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Replaces one entry atomically: build the new copy first, set the old entry
|
|
113
|
+
* aside, move the new copy into place, then delete the old entry. If anything
|
|
114
|
+
* fails, the previous entry is restored.
|
|
115
|
+
*
|
|
116
|
+
* @param details - Replace inputs
|
|
117
|
+
* @param details.from - Source path to copy
|
|
118
|
+
* @param details.targetDir - Directory that holds the destination
|
|
119
|
+
* @param details.name - Destination entry name inside `targetDir`
|
|
120
|
+
* @param details.recursive - Whether the source is a directory
|
|
121
|
+
* @returns Whether a previous entry existed
|
|
122
|
+
*/
|
|
123
|
+
export function atomicReplace({ from, targetDir, name, recursive }) {
|
|
124
|
+
const dest = join(targetDir, name)
|
|
125
|
+
const existed = entryExists(dest)
|
|
126
|
+
const tmp = join(targetDir, `${TEMP_PREFIX}${process.pid}-${name}`)
|
|
127
|
+
const old = join(targetDir, `${OLD_PREFIX}${process.pid}-${name}`)
|
|
128
|
+
|
|
129
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
130
|
+
cpSync(from, tmp, { recursive })
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if (existed) renameSync(dest, old)
|
|
134
|
+
renameSync(tmp, dest)
|
|
135
|
+
rmSync(old, { recursive: true, force: true })
|
|
136
|
+
} catch (error) {
|
|
137
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
138
|
+
if (existsSync(old) && !existsSync(dest)) renameSync(old, dest)
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { existed }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Creates a symlink at `dest` pointing to `from`, replacing anything already
|
|
147
|
+
* there. Used by `--link` so a development checkout can be used in place of a
|
|
148
|
+
* copied install.
|
|
149
|
+
*
|
|
150
|
+
* @param details - Link inputs
|
|
151
|
+
* @param details.from - Source path the link points at
|
|
152
|
+
* @param details.dest - Path of the link to create
|
|
153
|
+
* @param details.type - Symlink type (`"dir"` or `"file"`); Windows uses a
|
|
154
|
+
* junction for directories because it does not need extra privileges
|
|
155
|
+
*/
|
|
156
|
+
export function linkEntry({ from, dest, type = "dir" }) {
|
|
157
|
+
rmSync(dest, { recursive: true, force: true })
|
|
158
|
+
const linkType = process.platform === "win32" && type === "dir" ? "junction" : type
|
|
159
|
+
symlinkSync(from, dest, linkType)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolves the directory an installer writes into, based on its CLI options.
|
|
164
|
+
*
|
|
165
|
+
* @param options - Parsed command options
|
|
166
|
+
* @param options.target - Explicit target directory, when given
|
|
167
|
+
* @param options.project - Use a project-relative directory instead of global
|
|
168
|
+
* @param cwd - Project root used for `--project`
|
|
169
|
+
* @param kind - Subdirectory name under `.opencode` (for example `skills`)
|
|
170
|
+
* @returns Absolute path to the target directory
|
|
171
|
+
*/
|
|
172
|
+
export function resolveTarget(options, cwd, kind) {
|
|
173
|
+
if (options.target) return resolve(options.target)
|
|
174
|
+
if (options.project) return resolve(cwd, ".opencode", kind)
|
|
175
|
+
return join(homedir(), ".config", "opencode", kind)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Ensures a target directory exists before an install writes into it.
|
|
180
|
+
*
|
|
181
|
+
* @param targetDir - Directory to create
|
|
182
|
+
*/
|
|
183
|
+
export function ensureDir(targetDir) {
|
|
184
|
+
mkdirSync(targetDir, { recursive: true })
|
|
185
|
+
}
|
package/install.sh
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Bootstrap the CodeOps skills for OpenCode.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# curl -fsSL https://cdn.jsdelivr.net/npm/opencode-codeops@latest/install.sh | bash
|
|
7
|
+
# curl -fsSL .../install.sh | bash -s -- --project
|
|
8
|
+
# curl -fsSL .../install.sh | bash -s -- install-agents --project
|
|
9
|
+
# curl -fsSL .../install.sh | bash -s -- status
|
|
10
|
+
# curl -fsSL .../install.sh | bash -s -- uninstall
|
|
11
|
+
# CODEOPS_VERSION=<tag-or-version> curl -fsSL .../install.sh | bash
|
|
12
|
+
#
|
|
13
|
+
# This script is a thin wrapper around `npx opencode-codeops`. The package
|
|
14
|
+
# ships the skills, the agent definitions, and the installer, so nothing extra
|
|
15
|
+
# is downloaded here: npx fetches one versioned package and runs its installer.
|
|
16
|
+
# Pin a version with CODEOPS_VERSION (an npm dist-tag or exact version;
|
|
17
|
+
# defaults to "latest").
|
|
18
|
+
#
|
|
19
|
+
# Subcommands (see `npx opencode-codeops help` for the full option list):
|
|
20
|
+
# install-skills Install or upgrade the skills (default)
|
|
21
|
+
# install-agents Install or upgrade the subagents
|
|
22
|
+
# status Show the installed skills and agents version
|
|
23
|
+
# uninstall Remove the managed skills
|
|
24
|
+
# help Show installer help
|
|
25
|
+
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
readonly PACKAGE="opencode-codeops"
|
|
29
|
+
readonly VERSION="${CODEOPS_VERSION:-latest}"
|
|
30
|
+
|
|
31
|
+
# The version is interpolated into the npx package spec, so reject anything
|
|
32
|
+
# that could change its meaning or inject shell syntax.
|
|
33
|
+
if [[ ! "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
34
|
+
printf 'error: invalid CODEOPS_VERSION "%s"\n' "$VERSION" >&2
|
|
35
|
+
exit 2
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
if ! command -v npx >/dev/null 2>&1; then
|
|
39
|
+
printf 'error: npx is required but was not found on PATH. Install Node.js 18 or newer.\n' >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# The subcommand is optional and defaults to install-skills. Any remaining
|
|
44
|
+
# arguments are passed through to the installer unchanged.
|
|
45
|
+
subcommand="install-skills"
|
|
46
|
+
if (( $# > 0 )); then
|
|
47
|
+
case "$1" in
|
|
48
|
+
install | install-skills | install-agents | status | uninstall | help)
|
|
49
|
+
subcommand="$1"
|
|
50
|
+
shift
|
|
51
|
+
;;
|
|
52
|
+
esac
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
exec npx -y "${PACKAGE}@${VERSION}" "$subcommand" "$@"
|