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.
Files changed (102) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/LICENSE +21 -0
  3. package/README.md +171 -0
  4. package/_shared/auto-design.md +129 -0
  5. package/_shared/layout-convention.md +198 -0
  6. package/_shared/quality-profile.md +134 -0
  7. package/_shared/recommendation-hardening.md +166 -0
  8. package/_shared/scope-expansion-control.md +176 -0
  9. package/_shared/spec-first-ordering.md +79 -0
  10. package/_shared/zero-ambiguity-gate.md +311 -0
  11. package/agent-templates/codebase-scout.md +17 -0
  12. package/agent-templates/concurrency-auditor.md +5 -0
  13. package/agent-templates/design-challenger.md +26 -0
  14. package/agent-templates/financial-integrity-auditor.md +5 -0
  15. package/agent-templates/perf-auditor.md +23 -0
  16. package/agent-templates/phase-reviewer.md +54 -0
  17. package/agent-templates/plan-task-executor-opus.md +46 -0
  18. package/agent-templates/plan-task-executor.md +43 -0
  19. package/agent-templates/preflight-auditor.md +45 -0
  20. package/agent-templates/security-auditor.md +42 -0
  21. package/agent-templates/semantics-reviewer.md +5 -0
  22. package/agent-templates/spec-test-author.md +29 -0
  23. package/agents/concurrency-auditor.md +15 -0
  24. package/agents/correctness-reviewer.md +66 -0
  25. package/agents/demanding-executor.md +58 -0
  26. package/agents/design-challenger.md +38 -0
  27. package/agents/executor.md +55 -0
  28. package/agents/explorer.md +29 -0
  29. package/agents/financial-integrity-auditor.md +15 -0
  30. package/agents/performance-auditor.md +35 -0
  31. package/agents/preflight-auditor.md +57 -0
  32. package/agents/security-auditor.md +54 -0
  33. package/agents/semantics-reviewer.md +15 -0
  34. package/agents/spec-test-author.md +41 -0
  35. package/bin/codeops-worktree +244 -0
  36. package/bin/index.mjs +106 -0
  37. package/bin/install-agents.mjs +453 -0
  38. package/bin/install-skills.mjs +466 -0
  39. package/bin/lib/opencode-install.mjs +185 -0
  40. package/install.sh +55 -0
  41. package/package.json +73 -0
  42. package/plugin/index.ts +181 -0
  43. package/references/domains/compiler-and-language.md +28 -0
  44. package/references/domains/data-and-migration.md +22 -0
  45. package/references/domains/distributed-and-concurrent.md +26 -0
  46. package/references/domains/financial-system.md +28 -0
  47. package/references/domains/selection.md +19 -0
  48. package/references/domains/web-application.md +23 -0
  49. package/schemas/codeops-config.schema.json +56 -0
  50. package/scripts/check-version.mjs +163 -0
  51. package/scripts/codeops-migrate.sh +355 -0
  52. package/scripts/codeops-roadmap-compact.sh +232 -0
  53. package/scripts/codeops-roadmap-sync.sh +275 -0
  54. package/scripts/codeops_outcomes.py +155 -0
  55. package/scripts/codeops_plan.py +239 -0
  56. package/scripts/codeops_plan_migrate.py +318 -0
  57. package/scripts/codeops_worktree_snapshot.py +99 -0
  58. package/scripts/install_agents.py +288 -0
  59. package/scripts/release.mjs +533 -0
  60. package/skills/analyze-project/SKILL.md +28 -0
  61. package/skills/clean-comments/SKILL.md +22 -0
  62. package/skills/exec-plan/SKILL.md +267 -0
  63. package/skills/exec-plan/commit-modes.md +113 -0
  64. package/skills/exec-plan/execution-protocol.md +471 -0
  65. package/skills/git-commit/SKILL.md +35 -0
  66. package/skills/github-issues/SKILL.md +38 -0
  67. package/skills/grill-me/SKILL.md +342 -0
  68. package/skills/make-plan/SKILL.md +282 -0
  69. package/skills/make-plan/quality-checklist.md +96 -0
  70. package/skills/make-plan/templates.md +535 -0
  71. package/skills/make-plan/zero-ambiguity-gate.md +19 -0
  72. package/skills/make-requirements/SKILL.md +268 -0
  73. package/skills/make-requirements/discovery-phases.md +255 -0
  74. package/skills/make-requirements/review-and-add.md +73 -0
  75. package/skills/make-requirements/templates.md +296 -0
  76. package/skills/make-requirements/zero-ambiguity-gate.md +18 -0
  77. package/skills/outcome-review/SKILL.md +34 -0
  78. package/skills/preflight/SKILL.md +310 -0
  79. package/skills/preflight/dimensions.md +181 -0
  80. package/skills/preflight/report-format.md +300 -0
  81. package/skills/retro-requirements/SKILL.md +218 -0
  82. package/skills/retro-requirements/confidence-classification.md +45 -0
  83. package/skills/retro-requirements/phases.md +609 -0
  84. package/skills/retro-requirements/triage-gate.md +135 -0
  85. package/skills/roadmap/SKILL.md +381 -0
  86. package/skills/roadmap/stage-hooks.md +80 -0
  87. package/skills/roadmap/template.md +200 -0
  88. package/skills/setup-codeops/SKILL.md +94 -0
  89. package/skills/setup-codeops/migration.md +106 -0
  90. package/skills/setup-codeops/scaffold.md +99 -0
  91. package/skills/setup-routing/SKILL.md +102 -0
  92. package/skills/setup-routing/routing.md +44 -0
  93. package/skills/techdocs/SKILL.md +199 -0
  94. package/skills/techdocs/authoring-and-update.md +178 -0
  95. package/skills/techdocs/templates.md +655 -0
  96. package/skills/techdocs/vitepress-setup.md +143 -0
  97. package/skills/upgrade-plan/SKILL.md +75 -0
  98. package/skills/upgrade-plan/content-quality-gate.md +35 -0
  99. package/skills/upgrade-plan/upgrade-checklists.md +107 -0
  100. package/standards/coding-standards-full.md +124 -0
  101. package/standards/coding-standards.md +64 -0
  102. 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" "$@"