@skitterbyte/skitterspec-linear 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +56 -0
  2. package/assets/claude-md-section.md +39 -0
  3. package/assets/core/env.config.json.example +28 -0
  4. package/assets/core/env.config.md +99 -0
  5. package/assets/core/linear.config.json.example +39 -0
  6. package/assets/core/linear.config.md +121 -0
  7. package/assets/rules/spec-planning.md +152 -0
  8. package/assets/skills/spec/SKILL.md +232 -0
  9. package/assets/skills/spec-bug/SKILL.md +110 -0
  10. package/assets/skills/spec-cancel/SKILL.md +61 -0
  11. package/assets/skills/spec-complete/SKILL.md +87 -0
  12. package/assets/skills/spec-env/SKILL.md +63 -0
  13. package/assets/skills/spec-env-down/SKILL.md +64 -0
  14. package/assets/skills/spec-go/SKILL.md +134 -0
  15. package/assets/skills/spec-init/SKILL.md +84 -0
  16. package/assets/skills/spec-pull/SKILL.md +46 -0
  17. package/assets/skills/spec-push/SKILL.md +53 -0
  18. package/assets/skills/spec-ready/SKILL.md +50 -0
  19. package/assets/skills/spec-review/SKILL.md +69 -0
  20. package/assets/skills/spec-status/SKILL.md +46 -0
  21. package/bin/skitterspec-linear.js +26 -0
  22. package/package.json +38 -0
  23. package/src/cli.js +495 -0
  24. package/src/deprecate.js +138 -0
  25. package/src/env/config.js +165 -0
  26. package/src/env/integrate.js +46 -0
  27. package/src/env/provision.js +76 -0
  28. package/src/env/registry.js +95 -0
  29. package/src/env/render.js +26 -0
  30. package/src/env/resolve.js +202 -0
  31. package/src/env/teardown.js +109 -0
  32. package/src/env/trust.js +87 -0
  33. package/src/init.js +311 -0
  34. package/src/prompts.js +56 -0
  35. package/src/vendor/linear/cli-sync.js +256 -0
  36. package/src/vendor/linear/config.js +198 -0
  37. package/src/vendor/linear/mcp.js +112 -0
  38. package/src/vendor/sync-core/index.js +35 -0
  39. package/src/vendor/sync-core/src/apply.js +66 -0
  40. package/src/vendor/sync-core/src/base.js +83 -0
  41. package/src/vendor/sync-core/src/compare.js +99 -0
  42. package/src/vendor/sync-core/src/normalize.js +249 -0
  43. package/src/vendor/sync-core/src/pull.js +84 -0
  44. package/src/vendor/sync-core/src/push.js +106 -0
  45. package/src/vendor/sync-core/src/write.js +86 -0
package/src/cli.js ADDED
@@ -0,0 +1,495 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { execFileSync } = require('child_process')
6
+ const { init } = require('./init.js')
7
+ const {
8
+ detectReleaseTooling,
9
+ removeReleaseTooling,
10
+ releaseToolingNotice,
11
+ } = require('./deprecate.js')
12
+ const { loadEnvConfig } = require('./env/config.js')
13
+ const {
14
+ readRegistry,
15
+ writeRegistry,
16
+ allocateSlot,
17
+ freeSlot,
18
+ portOffset,
19
+ } = require('./env/registry.js')
20
+ const { resolveSpec, resolveBaseBranch, repoInfo, expandTokens, splitPrefix } = require('./env/resolve.js')
21
+ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
22
+ const { planUp } = require('./env/provision.js')
23
+ const { planDown } = require('./env/teardown.js')
24
+ const { planIntegrate } = require('./env/integrate.js')
25
+
26
+ const pkg = require('../package.json')
27
+
28
+ const HELP = `skitterspec — spec-driven-development for Claude Code
29
+
30
+ Usage:
31
+ skitterspec init [dir] Install the spec lifecycle skills, rule, and specs/
32
+ folders into a project
33
+ skitterspec update [dir] Re-copy skills + rule (overwrites), leaves specs/
34
+ and specs/.core/ config alone
35
+ skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
36
+ specs/.core/env.config.json). Subcommands:
37
+ up <spec> plan a worktree + Docker stack + opener
38
+ down <spec> tear down (guards; --keep-volumes, --force)
39
+ integrate <spec> plan rebase + fast-forward onto the base branch
40
+ status list provisioned specs + port blocks
41
+ resolve <spec> print resolved slug/type/branch/paths
42
+ skitterspec --help Show this help
43
+ skitterspec --version Print version
44
+
45
+ Options (init / update):
46
+ --force Overwrite skill/rule/script files that already exist
47
+ --dir <path> Target project dir (default: positional arg or cwd)
48
+ --no-claude-md Skip creating/patching CLAUDE.md
49
+ --yes, -y Accept defaults; skip the interactive setup prompts
50
+ --isolation / --no-isolation Enable/skip per-spec isolation (a git
51
+ worktree per spec; writes env.config.json)
52
+ --remove-release-tooling (update) Remove leftover release tooling
53
+ non-interactively (moved to skittership)
54
+
55
+ Examples:
56
+ npx @skitterbyte/skitterspec init
57
+ npx @skitterbyte/skitterspec init ./my-app --yes
58
+ npx @skitterbyte/skitterspec init --isolation
59
+ npx @skitterbyte/skitterspec update --force
60
+ `
61
+
62
+ function parse(argv) {
63
+ const opts = {
64
+ force: false,
65
+ claudeMd: true,
66
+ dir: null,
67
+ yes: false,
68
+ isolation: undefined,
69
+ removeReleaseTooling: false,
70
+ }
71
+ const positional = []
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const a = argv[i]
74
+ if (a === '--force') opts.force = true
75
+ else if (a === '--no-claude-md') opts.claudeMd = false
76
+ else if (a === '--yes' || a === '-y') opts.yes = true
77
+ else if (a === '--isolation') opts.isolation = true
78
+ else if (a === '--no-isolation') opts.isolation = false
79
+ else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
80
+ else if (a === '--dir') opts.dir = argv[++i]
81
+ else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
82
+ else positional.push(a)
83
+ }
84
+ return { opts, positional }
85
+ }
86
+
87
+ // After an `update`, clean up release tooling left by an older skitterspec (it
88
+ // now lives in @skitterbyte/skittership). Deletes only on an explicit interactive
89
+ // "yes" or --remove-release-tooling; a non-TTY/--yes run only prints the pointer,
90
+ // so CI never mutates files. Nothing to do when no release tooling is present.
91
+ async function cleanupReleaseTooling(dir, opts) {
92
+ const detection = detectReleaseTooling(dir)
93
+ if (!detection.present) return
94
+
95
+ const printRemoved = (removed) => {
96
+ process.stdout.write('\nRemoved release tooling (moved to @skitterbyte/skittership):\n')
97
+ for (const it of removed) process.stdout.write(` ${it}\n`)
98
+ process.stdout.write('Your CHANGELOG.md / RELEASES.md content was left untouched.\n')
99
+ }
100
+
101
+ if (opts.removeReleaseTooling) {
102
+ printRemoved(removeReleaseTooling(dir, detection).removed)
103
+ return
104
+ }
105
+
106
+ const interactive = Boolean(process.stdin.isTTY) && !opts.yes
107
+ if (!interactive) {
108
+ process.stdout.write(`\n${releaseToolingNotice()}\n`)
109
+ return
110
+ }
111
+
112
+ const { confirmRemoveReleaseTooling } = require('./prompts.js')
113
+ if (await confirmRemoveReleaseTooling(detection)) {
114
+ printRemoved(removeReleaseTooling(dir, detection).removed)
115
+ } else {
116
+ process.stdout.write(`\n${releaseToolingNotice()}\n`)
117
+ }
118
+ }
119
+
120
+ // --- spec-env: per-spec isolation engine (Phase 1: status + resolve) --------
121
+
122
+ // Print provisioned specs, their slots, and port blocks from the registry.
123
+ function specEnvStatus(dir, config) {
124
+ const registry = readRegistry(dir, config)
125
+ const names = Object.keys(registry.slots)
126
+ if (!names.length) {
127
+ process.stdout.write('spec-env: no provisioned specs.\n')
128
+ return
129
+ }
130
+ process.stdout.write('Provisioned specs:\n')
131
+ names
132
+ .sort((a, b) => registry.slots[a] - registry.slots[b])
133
+ .forEach((name) => {
134
+ const slot = registry.slots[name]
135
+ const off = portOffset(slot, config)
136
+ const hi = off + config.docker.portsPerSpec - 1
137
+ process.stdout.write(` ${name} slot ${slot} ports ${off}-${hi}\n`)
138
+ })
139
+ }
140
+
141
+ // Provision: allocate the slot, persist the registry, and print the plan the
142
+ // /spec-env skill executes (git worktree add, docker compose up, .env, opener).
143
+ function specEnvUp(dir, config, specArg) {
144
+ if (!specArg) {
145
+ process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
146
+ return
147
+ }
148
+ const spec = resolveSpec(specArg, dir, config)
149
+
150
+ // Trust the shared worktree root so edits into the freshly-provisioned worktree
151
+ // don't prompt. One absolute entry (the root) covers every spec; self-heals on
152
+ // every provision for teammates who only cloned and ran /spec-go.
153
+ const worktreeRootAbs = path.dirname(spec.worktreePath)
154
+ const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
155
+
156
+ const wantsDocker = spec.stack === 'docker' && config.docker.enabled
157
+
158
+ // Slot allocation is Docker-only: a worktree-only spec never touches the
159
+ // registry (no slot, no port block). Its re-run signal is the worktree already
160
+ // existing on disk (attach the branch, don't `-b`); a Docker spec's is its slot.
161
+ let slot = null
162
+ let attached
163
+ if (wantsDocker) {
164
+ const before = readRegistry(dir, config)
165
+ attached = Object.prototype.hasOwnProperty.call(before.slots, spec.folder)
166
+ const alloc = allocateSlot(before, spec.folder)
167
+ slot = alloc.slot
168
+ writeRegistry(dir, config, alloc.registry) // the engine's only write (Docker path)
169
+ } else {
170
+ attached = fs.existsSync(spec.worktreePath)
171
+ }
172
+
173
+ const plan = planUp(spec, { slot, attached }, config)
174
+
175
+ const out = []
176
+ out.push(`spec-env up: ${spec.folder} ${attached ? '(attached — existing)' : '(provisioned)'}`)
177
+ out.push('')
178
+ out.push(` worktree: ${plan.worktreePath}`)
179
+ out.push(` branch: ${plan.branch}`)
180
+ if (plan.slot !== null) {
181
+ const hi = plan.portOffset + config.docker.portsPerSpec - 1
182
+ out.push(` project: ${plan.projectName}`)
183
+ out.push(` slot: ${plan.slot} (ports ${plan.portOffset}-${hi})`)
184
+ } else {
185
+ out.push(' stack: worktree-only (no docker, no port block)')
186
+ }
187
+ if (trust.reason === 'malformed') {
188
+ out.push(
189
+ ' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
190
+ `\n add ${worktreeRootAbs} to permissions.additionalDirectories yourself`,
191
+ )
192
+ } else {
193
+ out.push(
194
+ ` trusted: ${worktreeRootAbs} ` +
195
+ `(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
196
+ )
197
+ }
198
+ out.push('')
199
+ out.push(' run these:')
200
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
201
+ if (plan.openCommand) out.push(` ${plan.openCommand}`)
202
+ if (plan.envContents) {
203
+ out.push('')
204
+ out.push(` write ${config.docker.envFile} in the worktree:`)
205
+ for (const line of plan.envContents.replace(/\n$/, '').split('\n')) {
206
+ out.push(` ${line}`)
207
+ }
208
+ }
209
+ process.stdout.write(out.join('\n') + '\n')
210
+ }
211
+
212
+ // A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
213
+ function gitReader(cwd) {
214
+ return (argv) => {
215
+ try {
216
+ return execFileSync('git', ['-C', cwd, ...argv], {
217
+ stdio: ['ignore', 'pipe', 'ignore'],
218
+ })
219
+ .toString()
220
+ .trim()
221
+ } catch {
222
+ return null
223
+ }
224
+ }
225
+ }
226
+
227
+ // Query a worktree's git state (side-effecting — kept in the CLI, not the pure
228
+ // planner). A missing worktree → nothing to lose (safe to tear down). `base` is
229
+ // the resolved integration branch; `merged` is true when HEAD is already an
230
+ // ancestor of it (fully landed), which lets teardown skip the unpushed guard.
231
+ function worktreeGitState(worktreePath, base) {
232
+ if (!fs.existsSync(worktreePath)) return { dirty: false, unpushed: false, merged: true }
233
+ const git = gitReader(worktreePath)
234
+
235
+ const status = git(['status', '--porcelain'])
236
+ const dirty = status !== null && status.length > 0
237
+
238
+ let unpushed = false
239
+ const ahead = git(['rev-list', '--count', '@{u}..HEAD'])
240
+ if (ahead !== null) {
241
+ // commits on HEAD's upstream branch not yet pushed
242
+ unpushed = Number(ahead) > 0
243
+ } else {
244
+ // no upstream configured → any commit on HEAD not on a remote counts
245
+ const local = git(['log', '--oneline', 'HEAD', '--not', '--remotes'])
246
+ unpushed = local !== null && local.length > 0
247
+ }
248
+
249
+ // merged = HEAD is an ancestor of base (every commit already landed). The
250
+ // worktree shares the object store, so `base` is visible here. `--is-ancestor`
251
+ // exits 0 when true; gitReader maps a non-zero exit to null.
252
+ const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
253
+
254
+ return { dirty, unpushed, merged }
255
+ }
256
+
257
+ // A deterministic-enough compact timestamp for backup filenames (CLI-only; the
258
+ // pure planner receives this as input so it stays testable).
259
+ function compactTimestamp() {
260
+ return new Date()
261
+ .toISOString()
262
+ .replace(/[-:]/g, '')
263
+ .replace(/\.\d+Z$/, '')
264
+ .replace('T', '-')
265
+ }
266
+
267
+ // Teardown: evaluate guards, print the plan, free the slot. Idempotent no-op
268
+ // when the spec was never provisioned / already torn down. Deliberately does NOT
269
+ // touch the trusted worktree root in .claude/settings.local.json — that entry is
270
+ // the shared parent of every spec's worktree and harmless when empty; removing it
271
+ // would just re-prompt on the next /spec-go (see spec: isolation-trusts-worktree-dir).
272
+ function specEnvDown(dir, config, specArg, flags) {
273
+ if (!specArg) {
274
+ process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
275
+ return
276
+ }
277
+ const spec = resolveSpec(specArg, dir, config)
278
+
279
+ // A worktree-only spec never held a slot but its worktree still needs removing,
280
+ // so "nothing to do" means neither a slot nor a worktree exists.
281
+ const registry = readRegistry(dir, config)
282
+ const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
283
+ if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
284
+ process.stdout.write(`spec-env down: ${spec.folder} is not provisioned — nothing to do.\n`)
285
+ return
286
+ }
287
+
288
+ const base = resolveBaseBranch(config, gitReader(dir))
289
+ const worktreeState = worktreeGitState(spec.worktreePath, base)
290
+ const plan = planDown(spec, config, flags, { worktreeState, timestamp: compactTimestamp() })
291
+
292
+ if (plan.blocked) {
293
+ process.stdout.write(
294
+ `spec-env down: blocked — ${plan.reason}.\n` +
295
+ 'Re-run with --force to tear down anyway (destroys the worktree).\n',
296
+ )
297
+ return
298
+ }
299
+
300
+ // Free the slot (the engine's only write on down) — only if one was held; a
301
+ // worktree-only teardown never touches the registry.
302
+ if (hasSlot) {
303
+ writeRegistry(dir, config, freeSlot(registry, spec.folder))
304
+ }
305
+
306
+ const out = []
307
+ out.push(`spec-env down: ${spec.folder}${hasSlot ? ' (slot freed)' : ''}`)
308
+ out.push('')
309
+ out.push(` worktree: ${spec.worktreePath}`)
310
+ out.push(` volumes: ${plan.volumesDropped ? 'dropped' : 'kept'}`)
311
+ if (plan.backupPath) out.push(` backup: ${plan.backupPath}`)
312
+ else if (plan.volumesDropped) out.push(' backup: none (no docker.backupCommand set)')
313
+ out.push('')
314
+ out.push(' run these:')
315
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
316
+ process.stdout.write(out.join('\n') + '\n')
317
+ }
318
+
319
+ // Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
320
+ // Queries git for the facts, prints the plan / block / no-op. The /spec-complete
321
+ // skill executes the printed commands (and aborts a conflicting rebase).
322
+ function specEnvIntegrate(dir, config, specArg) {
323
+ if (!specArg) {
324
+ process.stdout.write('Usage: skitterspec spec-env integrate <spec>\n')
325
+ return
326
+ }
327
+
328
+ // /spec-complete runs this from inside the worktree, but the spec's coordinates
329
+ // (worktreePath via {repo}, the base branch) must resolve against the PRIMARY
330
+ // checkout. Resolve it first (parent of the shared git dir) and anchor
331
+ // everything to it, so integrate works whether invoked from main or a worktree.
332
+ const commonDir = gitReader(dir)(['rev-parse', '--git-common-dir'])
333
+ const mainRepoPath = commonDir ? path.dirname(path.resolve(dir, commonDir)) : dir
334
+
335
+ // A spec authored entirely on its branch may not exist in the primary
336
+ // checkout's specs/** (it was never committed to base) — but its worktree
337
+ // does, and the worktree path is derivable from config without the folder.
338
+ // Offer it as a fallback search location so integrate can still find the spec.
339
+ const { slug } = splitPrefix(path.basename(specArg))
340
+ const { repo, repoSlug } = repoInfo(mainRepoPath)
341
+ const wtTokens = { repo, repoSlug, slug }
342
+ const worktreeGuess = path.resolve(
343
+ mainRepoPath,
344
+ expandTokens(config.worktree.root, wtTokens),
345
+ expandTokens(config.worktree.folderPattern, wtTokens),
346
+ )
347
+ const spec = resolveSpec(specArg, mainRepoPath, config, { searchDirs: [worktreeGuess] })
348
+
349
+ if (!fs.existsSync(spec.worktreePath)) {
350
+ process.stdout.write(
351
+ `spec-env integrate: ${spec.folder} has no worktree — nothing to integrate.\n`,
352
+ )
353
+ return
354
+ }
355
+
356
+ const base = resolveBaseBranch(config, gitReader(mainRepoPath))
357
+ const wtGit = gitReader(spec.worktreePath)
358
+ const status = wtGit(['status', '--porcelain'])
359
+ const dirty = status !== null && status.length > 0
360
+ const ahead = wtGit(['rev-list', '--count', `${base}..HEAD`])
361
+ const aheadOfBase = ahead !== null && Number(ahead) > 0
362
+
363
+ const plan = planIntegrate(spec, config, { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath })
364
+
365
+ if (plan.blocked) {
366
+ process.stdout.write(`spec-env integrate: blocked — ${plan.reason}.\n`)
367
+ return
368
+ }
369
+ if (plan.noop) {
370
+ process.stdout.write(
371
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
372
+ )
373
+ return
374
+ }
375
+
376
+ const out = []
377
+ out.push(`spec-env integrate: ${spec.folder}`)
378
+ out.push('')
379
+ out.push(` base: ${plan.base}`)
380
+ out.push(` branch: ${plan.branch}`)
381
+ out.push(` worktree: ${spec.worktreePath}`)
382
+ out.push('')
383
+ out.push(' run these (abort the rebase on conflict):')
384
+ for (const cmd of plan.commands) out.push(` ${cmd}`)
385
+ process.stdout.write(out.join('\n') + '\n')
386
+ }
387
+
388
+ // Print the resolved identity/coordinates for a single spec.
389
+ function specEnvResolve(dir, config, specArg) {
390
+ if (!specArg) {
391
+ process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
392
+ return
393
+ }
394
+ const r = resolveSpec(specArg, dir, config)
395
+ process.stdout.write(
396
+ `spec: ${r.folder} (${r.bucket})\n` +
397
+ `type/slug: ${r.type} / ${r.slug}\n` +
398
+ `branch: ${r.branch}\n` +
399
+ `worktree: ${r.worktreePath}\n` +
400
+ `project: ${r.projectName}\n`,
401
+ )
402
+ }
403
+
404
+ // Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
405
+ // message when the feature isn't enabled (no specs/.core/env.config.json).
406
+ function specEnv(rest) {
407
+ const [sub, ...args] = rest
408
+ let dir = process.cwd()
409
+ const positional = []
410
+ const flags = { keepVolumes: false, force: false }
411
+ for (let i = 0; i < args.length; i++) {
412
+ if (args[i] === '--dir') dir = path.resolve(args[++i])
413
+ else if (args[i] === '--keep-volumes') flags.keepVolumes = true
414
+ else if (args[i] === '--force') flags.force = true
415
+ else positional.push(args[i])
416
+ }
417
+ dir = path.resolve(dir)
418
+
419
+ const { config, present } = loadEnvConfig(dir)
420
+ if (!present) {
421
+ process.stdout.write(
422
+ 'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
423
+ 'Opt in by copying specs/.core/env.config.json.example → env.config.json.\n',
424
+ )
425
+ return
426
+ }
427
+
428
+ switch (sub) {
429
+ case 'up':
430
+ specEnvUp(dir, config, positional[0])
431
+ break
432
+ case 'down':
433
+ specEnvDown(dir, config, positional[0], flags)
434
+ break
435
+ case 'integrate':
436
+ specEnvIntegrate(dir, config, positional[0])
437
+ break
438
+ case 'status':
439
+ specEnvStatus(dir, config)
440
+ break
441
+ case 'resolve':
442
+ specEnvResolve(dir, config, positional[0])
443
+ break
444
+ default:
445
+ process.stdout.write(
446
+ 'Usage: skitterspec spec-env <up|down|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
447
+ )
448
+ }
449
+ }
450
+
451
+ async function run(argv) {
452
+ if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {
453
+ process.stdout.write(HELP)
454
+ return
455
+ }
456
+ if (argv.includes('--version') || argv.includes('-v')) {
457
+ process.stdout.write(`${pkg.version}\n`)
458
+ return
459
+ }
460
+
461
+ const [cmd, ...rest] = argv
462
+
463
+ if (cmd === 'spec-env') {
464
+ specEnv(rest)
465
+ return
466
+ }
467
+
468
+ const { opts, positional } = parse(rest)
469
+ const dir = path.resolve(opts.dir || positional[0] || process.cwd())
470
+
471
+ switch (cmd) {
472
+ case 'init': {
473
+ // Isolation defaults OFF; a flag or an interactive "yes" opts in.
474
+ let isolation = opts.isolation === true
475
+
476
+ const interactive = Boolean(process.stdin.isTTY) && !opts.yes
477
+ if (interactive) {
478
+ const { promptSetup } = require('./prompts.js')
479
+ const result = await promptSetup({ isolationSeed: isolation })
480
+ isolation = result.isolation
481
+ }
482
+
483
+ await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
484
+ break
485
+ }
486
+ case 'update':
487
+ await init({ dir, force: true, claudeMd: opts.claudeMd, mode: 'update' })
488
+ await cleanupReleaseTooling(dir, opts)
489
+ break
490
+ default:
491
+ throw new Error(`unknown command: ${cmd} (try --help)`)
492
+ }
493
+ }
494
+
495
+ module.exports = { run, parse }
@@ -0,0 +1,138 @@
1
+ 'use strict'
2
+
3
+ // Cleanup path for projects that installed release tooling from an older
4
+ // skitterspec (before it moved to @skitterbyte/skittership). `skitterspec update`
5
+ // detects the leftover files and — only on an explicit interactive "yes" or the
6
+ // --remove-release-tooling flag — removes exactly what skitterspec used to
7
+ // install. It never touches the user's generated CHANGELOG.md / RELEASES.md, nor
8
+ // any script it didn't add.
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ const SKITTERSHIP = '@skitterbyte/skittership'
14
+
15
+ // Files/dirs skitterspec used to install for release tooling (repo-relative).
16
+ const RELEASE_PATHS = [
17
+ 'skitterspec.config.json',
18
+ path.join('scripts', 'generate-changelog.js'),
19
+ path.join('scripts', 'generate-releases.js'),
20
+ path.join('scripts', 'lib', 'git-commits.js'),
21
+ path.join('scripts', 'lib', 'config.js'),
22
+ path.join('.claude', 'skills', 'commit'),
23
+ path.join('.claude', 'rules', 'commit-messages.md'),
24
+ ]
25
+
26
+ // npm scripts skitterspec used to wire; only removed when their value still
27
+ // matches the generator command (so a user's custom override is preserved).
28
+ const HELPER_SCRIPTS = {
29
+ changelog: 'node scripts/generate-changelog.js',
30
+ 'changelog:retro': 'node scripts/generate-changelog.js --retro',
31
+ releases: 'node scripts/generate-releases.js',
32
+ 'releases:retro': 'node scripts/generate-releases.js --retro',
33
+ }
34
+
35
+ function readPkg(dir) {
36
+ const pkgPath = path.join(dir, 'package.json')
37
+ if (!fs.existsSync(pkgPath)) return null
38
+ try {
39
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ // Does package.json have a `version` script that runs the release generators?
46
+ function versionHookReferencesGenerators(pkg) {
47
+ const v = pkg && pkg.scripts && pkg.scripts.version
48
+ return typeof v === 'string' && /generate-(changelog|releases)\.js/.test(v)
49
+ }
50
+
51
+ // Is skittership the source of the release tooling here (rather than a leftover
52
+ // legacy skitterspec install)? True when the project has adopted skittership —
53
+ // its config file is present, or it's a declared dependency. In that case the
54
+ // release files are skittership's current install and must NOT be offered for
55
+ // removal (they'd just come back on the next `skittership init`).
56
+ function skittershipAdopted(dir) {
57
+ if (fs.existsSync(path.join(dir, 'skittership.config.json'))) return true
58
+ const pkg = readPkg(dir)
59
+ const deps = Object.assign({}, pkg && pkg.dependencies, pkg && pkg.devDependencies)
60
+ return Boolean(deps['@skitterbyte/skittership'])
61
+ }
62
+
63
+ // Report which release-tooling artifacts are present. `present` is true only when
64
+ // there are legacy artifacts to clean up (a file/dir or a generator-driven version
65
+ // hook) AND skittership hasn't been adopted — otherwise the files belong to a
66
+ // live skittership install, not an old bundled-skitterspec one.
67
+ function detectReleaseTooling(dir) {
68
+ const files = RELEASE_PATHS.filter((rel) => fs.existsSync(path.join(dir, rel)))
69
+ const pkg = readPkg(dir)
70
+ const versionHook = versionHookReferencesGenerators(pkg)
71
+ const adopted = skittershipAdopted(dir)
72
+ return { present: (files.length > 0 || versionHook) && !adopted, files, versionHook, adopted }
73
+ }
74
+
75
+ // Remove an emptied directory, walking up while parents are left empty. Never
76
+ // climbs out of `dir`.
77
+ function pruneEmptyDirs(dir, startAbs) {
78
+ let cur = startAbs
79
+ while (cur.startsWith(dir) && cur !== dir && fs.existsSync(cur)) {
80
+ if (fs.readdirSync(cur).length > 0) break
81
+ fs.rmdirSync(cur)
82
+ cur = path.dirname(cur)
83
+ }
84
+ }
85
+
86
+ // Remove exactly the detected artifacts + unwire the version hook. Returns a
87
+ // report of what was removed. Scoped and non-destructive: only skitterspec's own
88
+ // files, and only npm scripts whose value still matches the generator command.
89
+ function removeReleaseTooling(dir, detection = detectReleaseTooling(dir)) {
90
+ const removed = []
91
+
92
+ for (const rel of detection.files) {
93
+ const abs = path.join(dir, rel)
94
+ if (!fs.existsSync(abs)) continue
95
+ fs.rmSync(abs, { recursive: true, force: true })
96
+ removed.push(rel)
97
+ pruneEmptyDirs(dir, path.dirname(abs))
98
+ }
99
+
100
+ const pkg = readPkg(dir)
101
+ if (pkg && pkg.scripts) {
102
+ let changed = false
103
+ if (versionHookReferencesGenerators(pkg)) {
104
+ delete pkg.scripts.version
105
+ removed.push('package.json (version hook)')
106
+ changed = true
107
+ }
108
+ for (const [name, cmd] of Object.entries(HELPER_SCRIPTS)) {
109
+ if (pkg.scripts[name] === cmd) {
110
+ delete pkg.scripts[name]
111
+ changed = true
112
+ }
113
+ }
114
+ if (changed) {
115
+ if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts
116
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
117
+ }
118
+ }
119
+
120
+ return { removed }
121
+ }
122
+
123
+ // One-line pointer shown when we detect release tooling but don't remove it
124
+ // (declined, or a non-interactive run).
125
+ function releaseToolingNotice() {
126
+ return (
127
+ `Release tooling has moved to ${SKITTERSHIP} — run ` +
128
+ `npx ${SKITTERSHIP} init to keep it (your config is migrated automatically).`
129
+ )
130
+ }
131
+
132
+ module.exports = {
133
+ detectReleaseTooling,
134
+ removeReleaseTooling,
135
+ releaseToolingNotice,
136
+ RELEASE_PATHS,
137
+ SKITTERSHIP,
138
+ }