@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
@@ -0,0 +1,109 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure teardown planner for `spec-env down`.
5
+ *
6
+ * `planDown` evaluates the dirty/unpushed guards, plans an optional config-driven
7
+ * pre-drop backup, and returns the exact commands the `/spec-env-down` skill runs
8
+ * (`docker compose down` [+`--volumes`], `git worktree remove`). It performs no
9
+ * side effects: the caller (the CLI) queries git for `worktreeState` and supplies
10
+ * a `timestamp`, keeping this deterministic and unit-testable with no live
11
+ * git/docker.
12
+ *
13
+ * Volumes are the only destructive action — dropped by default (reclaims disk)
14
+ * unless `--keep-volumes`, and always backed up first when a `backupCommand` is
15
+ * configured.
16
+ */
17
+
18
+ const { expandTokens } = require('./resolve.js')
19
+
20
+ /**
21
+ * @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
22
+ * @param {object} config normalised env config.
23
+ * @param {object} flags { keepVolumes, force }
24
+ * @param {object} ctx { worktreeState: { dirty, unpushed, merged }, timestamp }
25
+ * @returns {object} { blocked, reason, commands, backupCommand, backupPath,
26
+ * volumesDropped }
27
+ */
28
+ function planDown(spec, config, flags, ctx) {
29
+ const { worktreeState = {}, timestamp } = ctx || {}
30
+ const force = Boolean(flags && flags.force)
31
+ const keepVolumes = Boolean(flags && flags.keepVolumes)
32
+
33
+ // --- guards (overridable with --force) ---
34
+ if (!force) {
35
+ if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
36
+ return blocked('worktree has uncommitted changes')
37
+ }
38
+ // Unpushed commits are only unsafe when they aren't already integrated into
39
+ // the base branch. A branch merged into base carries nothing to lose even
40
+ // with no remote — so /spec-complete's local land-then-teardown needs no
41
+ // --force. Block only when the commits are both unpushed AND unmerged.
42
+ if (
43
+ config.guards.refuseTeardownIfUnpushed &&
44
+ worktreeState.unpushed &&
45
+ !worktreeState.merged
46
+ ) {
47
+ return blocked('worktree has unpushed commits not yet merged into the base branch')
48
+ }
49
+ }
50
+
51
+ // Docker teardown only applies to a Docker-escalated spec. A worktree-only spec
52
+ // (Stack: worktree) has no stack/volumes even when the project master switch is
53
+ // on — tearing it down is just removing the worktree. A spec resolved without an
54
+ // explicit stack (legacy/tests) follows the master switch (pre-`Stack` behaviour).
55
+ const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
56
+ const wantsDocker = stack === 'docker' && config.docker.enabled
57
+
58
+ const commands = []
59
+ const volumesDropped = !keepVolumes && wantsDocker
60
+
61
+ // --- optional pre-drop backup (only when volumes are actually dropped) ---
62
+ let backupCommand = null
63
+ let backupPath = null
64
+ if (volumesDropped && config.docker.backupCommand) {
65
+ backupPath = `.spec-env/backups/${spec.slug}-${timestamp}.dump`
66
+ backupCommand = expandTokens(config.docker.backupCommand, {
67
+ backupPath,
68
+ slug: spec.slug,
69
+ projectName: spec.projectName,
70
+ timestamp: String(timestamp),
71
+ })
72
+ commands.push(backupCommand)
73
+ }
74
+
75
+ // --- docker compose down (drop volumes unless kept) ---
76
+ if (wantsDocker) {
77
+ const base = `docker compose --project-name ${spec.projectName} down`
78
+ commands.push(volumesDropped ? `${base} --volumes` : base)
79
+ }
80
+
81
+ // --- remove the worktree (force needed if we bypassed a dirty guard) ---
82
+ commands.push(
83
+ force
84
+ ? `git worktree remove --force ${spec.worktreePath}`
85
+ : `git worktree remove ${spec.worktreePath}`,
86
+ )
87
+
88
+ // --- delete the branch (safe: -d refuses an unmerged branch, never -D) ---
89
+ // Runs after the worktree remove frees the branch. On a forced teardown of an
90
+ // unmerged branch this fails loudly; the skill relays it rather than -D-ing.
91
+ if (spec.branch) {
92
+ commands.push(`git branch -d ${spec.branch}`)
93
+ }
94
+
95
+ return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
96
+ }
97
+
98
+ function blocked(reason) {
99
+ return {
100
+ blocked: true,
101
+ reason,
102
+ commands: [],
103
+ backupCommand: null,
104
+ backupPath: null,
105
+ volumesDropped: false,
106
+ }
107
+ }
108
+
109
+ module.exports = { planDown }
@@ -0,0 +1,87 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Trust a per-spec worktree directory with Claude Code.
5
+ *
6
+ * Worktrees live outside the primary checkout (`../{repo}-wt/{slug}`), so Claude
7
+ * Code treats them as untrusted and prompts on every edit until the operator
8
+ * grants access. Registering the shared worktree root in
9
+ * `permissions.additionalDirectories` lifts those prompts for every spec at once.
10
+ *
11
+ * The root is an **absolute** path (relative entries aren't reliable in
12
+ * `additionalDirectories`) and therefore machine-specific, so it belongs in the
13
+ * gitignored `.claude/settings.local.json` — never committed config. This merge
14
+ * is deliberately conservative: it preserves every existing key (notably
15
+ * `permissions.allow`), dedups by exact path, and refuses to clobber a file it
16
+ * can't parse. `fs` is the only side effect; callers own the reporting.
17
+ */
18
+
19
+ const fs = require('node:fs')
20
+ const path = require('node:path')
21
+
22
+ function isObject(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
24
+ }
25
+
26
+ // Absolute path to the machine-local Claude Code settings for `dir`.
27
+ function settingsPath(dir) {
28
+ return path.join(dir, '.claude', 'settings.local.json')
29
+ }
30
+
31
+ function writeSettings(file, settings) {
32
+ fs.mkdirSync(path.dirname(file), { recursive: true })
33
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n')
34
+ }
35
+
36
+ /**
37
+ * Ensure `rootAbs` is listed in `permissions.additionalDirectories` of `dir`'s
38
+ * `.claude/settings.local.json`. Idempotent and non-destructive. Returns
39
+ * `{ changed, reason }` where reason is one of:
40
+ * - `created` — the settings file was absent and was created
41
+ * - `added` — the root was merged into an existing file
42
+ * - `present` — the root was already listed (no write)
43
+ * - `malformed` — the file exists but isn't parseable JSON (left untouched)
44
+ */
45
+ function ensureWorktreeDirTrusted(dir, rootAbs) {
46
+ const file = settingsPath(dir)
47
+
48
+ let raw
49
+ try {
50
+ raw = fs.readFileSync(file, 'utf-8')
51
+ } catch (error) {
52
+ if (error.code === 'ENOENT') {
53
+ writeSettings(file, { permissions: { additionalDirectories: [rootAbs] } })
54
+ return { changed: true, reason: 'created' }
55
+ }
56
+ throw error
57
+ }
58
+
59
+ let parsed
60
+ try {
61
+ parsed = JSON.parse(raw)
62
+ } catch {
63
+ return { changed: false, reason: 'malformed' }
64
+ }
65
+ if (!isObject(parsed)) return { changed: false, reason: 'malformed' }
66
+
67
+ const permissions = isObject(parsed.permissions) ? parsed.permissions : {}
68
+ const dirs = Array.isArray(permissions.additionalDirectories)
69
+ ? permissions.additionalDirectories
70
+ : []
71
+
72
+ if (dirs.includes(rootAbs)) return { changed: false, reason: 'present' }
73
+
74
+ writeSettings(file, {
75
+ ...parsed,
76
+ permissions: {
77
+ ...permissions,
78
+ additionalDirectories: [...dirs, rootAbs],
79
+ },
80
+ })
81
+ return { changed: true, reason: 'added' }
82
+ }
83
+
84
+ module.exports = {
85
+ ensureWorktreeDirTrusted,
86
+ settingsPath,
87
+ }
package/src/init.js ADDED
@@ -0,0 +1,311 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
7
+ const { repoInfo, expandTokens } = require('./env/resolve.js')
8
+
9
+ const ASSETS = path.join(__dirname, '..', 'assets')
10
+
11
+ // Skills, rules, and specs/.core templates are discovered from the bundled assets
12
+ // tree rather than hardcoded, so each distribution installs exactly what it ships:
13
+ // the tracker-free base carries the neutral skill set + env.config templates; a
14
+ // provider superset (built by composing its fragments in) additionally carries its
15
+ // sync skills and provider config templates, and they install with no code change.
16
+ function listSkills() {
17
+ const dir = path.join(ASSETS, 'skills')
18
+ return fs
19
+ .readdirSync(dir, { withFileTypes: true })
20
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
21
+ .map((e) => e.name)
22
+ .sort()
23
+ }
24
+
25
+ function listRules() {
26
+ return fs
27
+ .readdirSync(path.join(ASSETS, 'rules'))
28
+ .filter((f) => f.endsWith('.md'))
29
+ .sort()
30
+ }
31
+
32
+ // Templates scaffolded into specs/.core/ (the *.example configs + their *.md docs).
33
+ // A consumer copies an example → live config to adopt the matching feature.
34
+ function listCoreTemplates() {
35
+ return fs
36
+ .readdirSync(path.join(ASSETS, 'core'))
37
+ .filter((f) => f.endsWith('.example') || f.endsWith('.md'))
38
+ .sort()
39
+ .map((f) => path.join('core', f))
40
+ }
41
+
42
+ const SKILLS = listSkills()
43
+
44
+ const RULES = listRules()
45
+
46
+ const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
47
+
48
+ // Opt-in config templates, scaffolded into specs/.core/ (the base ships the
49
+ // env.config isolation templates; a provider superset also ships its own).
50
+ const CORE_FILES = listCoreTemplates()
51
+
52
+ const SPEC_MARKER_START = '<!-- skitterspec:start -->'
53
+ const SPEC_MARKER_END = '<!-- skitterspec:end -->'
54
+
55
+ const report = { created: [], updated: [], skipped: [], removed: [], warnings: [] }
56
+
57
+ // Folder index files scaffolded by earlier versions, now retired. `init`/`update`
58
+ // deletes any left behind so upgrading projects don't keep stale caches.
59
+ const RETIRED_FILES = [
60
+ path.join('specs', 'backlog', '00-index.md'),
61
+ path.join('specs', 'complete', '00-index.md'),
62
+ ]
63
+
64
+ function rel(dir, p) {
65
+ return path.relative(dir, p) || '.'
66
+ }
67
+
68
+ function ensureDir(p) {
69
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true })
70
+ }
71
+
72
+ function writeFile(dir, target, content, { force }) {
73
+ if (fs.existsSync(target)) {
74
+ if (!force) {
75
+ report.skipped.push(rel(dir, target))
76
+ return
77
+ }
78
+ const existing = fs.readFileSync(target, 'utf8')
79
+ if (existing === content) {
80
+ report.skipped.push(rel(dir, target))
81
+ return
82
+ }
83
+ fs.writeFileSync(target, content)
84
+ report.updated.push(rel(dir, target))
85
+ return
86
+ }
87
+ ensureDir(path.dirname(target))
88
+ fs.writeFileSync(target, content)
89
+ report.created.push(rel(dir, target))
90
+ }
91
+
92
+ function copyAsset(dir, assetRelPath, targetAbs, opts) {
93
+ const content = fs.readFileSync(path.join(ASSETS, assetRelPath), 'utf8')
94
+ writeFile(dir, targetAbs, content, opts)
95
+ }
96
+
97
+ function installSkills(dir, opts) {
98
+ for (const name of SKILLS) {
99
+ copyAsset(
100
+ dir,
101
+ path.join('skills', name, 'SKILL.md'),
102
+ path.join(dir, '.claude', 'skills', name, 'SKILL.md'),
103
+ opts,
104
+ )
105
+ }
106
+ }
107
+
108
+ function installRule(dir, opts) {
109
+ for (const name of RULES) {
110
+ copyAsset(
111
+ dir,
112
+ path.join('rules', name),
113
+ path.join(dir, '.claude', 'rules', name),
114
+ opts,
115
+ )
116
+ }
117
+ }
118
+
119
+ function installFolders(dir) {
120
+ for (const folder of SPEC_FOLDERS) {
121
+ const abs = path.join(dir, 'specs', folder)
122
+ if (!fs.existsSync(abs)) {
123
+ ensureDir(abs)
124
+ report.created.push(rel(dir, abs) + '/')
125
+ // keep otherwise-empty folders in git
126
+ if (!fs.readdirSync(abs).length) {
127
+ fs.writeFileSync(path.join(abs, '.gitkeep'), '')
128
+ }
129
+ } else {
130
+ report.skipped.push(rel(dir, abs) + '/')
131
+ }
132
+ }
133
+ }
134
+
135
+ // Delete retired folder index files left by earlier versions. If removing one
136
+ // empties its bucket, drop a `.gitkeep` so the folder stays tracked in git.
137
+ function removeRetiredFiles(dir) {
138
+ for (const relPath of RETIRED_FILES) {
139
+ const target = path.join(dir, relPath)
140
+ if (!fs.existsSync(target)) continue
141
+ fs.unlinkSync(target)
142
+ report.removed.push(rel(dir, target))
143
+ const folder = path.dirname(target)
144
+ if (fs.existsSync(folder) && !fs.readdirSync(folder).length) {
145
+ fs.writeFileSync(path.join(folder, '.gitkeep'), '')
146
+ }
147
+ }
148
+ }
149
+
150
+ // Scaffold the opt-in isolation templates into specs/.core/ (the example config
151
+ // + its field docs). Copied, not activated: the feature stays off until the
152
+ // consumer copies env.config.json.example → env.config.json.
153
+ function installCore(dir, opts) {
154
+ for (const asset of CORE_FILES) {
155
+ copyAsset(
156
+ dir,
157
+ asset,
158
+ path.join(dir, 'specs', '.core', path.basename(asset)),
159
+ opts,
160
+ )
161
+ }
162
+ }
163
+
164
+ // Activate opt-in per-spec isolation: write specs/.core/env.config.json from the
165
+ // example asset so /spec-go provisions a worktree for every in-progress spec.
166
+ // Only called when the operator opts in, and never on `update` (adopting isolation
167
+ // is a deliberate choice, not something a re-sync flips on). Idempotent: writeFile
168
+ // never clobbers an existing env.config.json without --force.
169
+ function installIsolation(dir, { enabled }, opts) {
170
+ if (!enabled) return
171
+ copyAsset(
172
+ dir,
173
+ path.join('core', 'env.config.json.example'),
174
+ path.join(dir, 'specs', '.core', 'env.config.json'),
175
+ opts,
176
+ )
177
+ trustWorktreeRoot(dir)
178
+ }
179
+
180
+ // Seed the absolute worktree root into .claude/settings.local.json (gitignored)
181
+ // so the operator enabling isolation isn't prompted on every edit into a
182
+ // freshly-provisioned worktree. Best-effort: an unreadable config or malformed
183
+ // settings file is reported, never fatal. `spec-env up` re-ensures this on every
184
+ // provision, so a miss here self-heals.
185
+ function trustWorktreeRoot(dir) {
186
+ let root
187
+ try {
188
+ const cfg = JSON.parse(
189
+ fs.readFileSync(path.join(dir, 'specs', '.core', 'env.config.json'), 'utf8'),
190
+ )
191
+ root = cfg && cfg.worktree && cfg.worktree.root
192
+ } catch {
193
+ /* fall through to the warning below */
194
+ }
195
+ if (!root) {
196
+ report.warnings.push('could not read worktree.root — skipped trusting the worktree dir')
197
+ return
198
+ }
199
+ const { repo, repoSlug } = repoInfo(dir)
200
+ const rootAbs = path.resolve(dir, expandTokens(root, { repo, repoSlug }))
201
+ const res = ensureWorktreeDirTrusted(dir, rootAbs)
202
+ const label = '.claude/settings.local.json (trusted worktree root)'
203
+ if (res.reason === 'malformed') {
204
+ report.warnings.push(
205
+ '.claude/settings.local.json is not valid JSON — did not trust the worktree' +
206
+ ` dir; add ${rootAbs} to permissions.additionalDirectories yourself`,
207
+ )
208
+ } else if (res.reason === 'created') {
209
+ report.created.push(label)
210
+ } else if (res.reason === 'added') {
211
+ report.updated.push(label)
212
+ } else {
213
+ report.skipped.push('.claude/settings.local.json (worktree root already trusted)')
214
+ }
215
+ }
216
+
217
+ function installClaudeMd(dir, { mode }) {
218
+ const section = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
219
+ const block = `${SPEC_MARKER_START}\n${section}\n${SPEC_MARKER_END}\n`
220
+ const target = path.join(dir, 'CLAUDE.md')
221
+
222
+ if (!fs.existsSync(target)) {
223
+ fs.writeFileSync(target, `# ${path.basename(dir)}\n\n${block}`)
224
+ report.created.push('CLAUDE.md')
225
+ return
226
+ }
227
+
228
+ const existing = fs.readFileSync(target, 'utf8')
229
+
230
+ if (existing.includes(SPEC_MARKER_START) && existing.includes(SPEC_MARKER_END)) {
231
+ if (mode !== 'update') {
232
+ report.skipped.push('CLAUDE.md (spec workflow already present)')
233
+ return
234
+ }
235
+ const re = new RegExp(`${SPEC_MARKER_START}[\\s\\S]*?${SPEC_MARKER_END}\\n?`)
236
+ const next = existing.replace(re, block)
237
+ if (next === existing) {
238
+ report.skipped.push('CLAUDE.md')
239
+ } else {
240
+ fs.writeFileSync(target, next)
241
+ report.updated.push('CLAUDE.md (spec workflow section)')
242
+ }
243
+ return
244
+ }
245
+
246
+ if (/^##\s+Spec workflow/m.test(existing)) {
247
+ report.skipped.push('CLAUDE.md (has a manual "Spec workflow" section — left alone)')
248
+ return
249
+ }
250
+
251
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n'
252
+ fs.writeFileSync(target, `${existing}${sep}${block}`)
253
+ report.updated.push('CLAUDE.md (appended spec workflow section)')
254
+ }
255
+
256
+ function printReport(dir, mode) {
257
+ const line = (label, items) => {
258
+ if (!items.length) return
259
+ process.stdout.write(`\n${label}:\n`)
260
+ for (const it of items) process.stdout.write(` ${it}\n`)
261
+ }
262
+ process.stdout.write(`\nskitterspec ${mode} → ${dir}\n`)
263
+ line('created', report.created)
264
+ line('updated', report.updated)
265
+ line('removed', report.removed)
266
+ line('unchanged', report.skipped)
267
+ if (report.warnings.length) {
268
+ process.stdout.write('\nwarnings:\n')
269
+ for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
270
+ }
271
+ const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
272
+ const isolationNote = isolationOn
273
+ ? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
274
+ ' at /spec-go (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
275
+ : 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
276
+ ' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
277
+ process.stdout.write(
278
+ '\nDone. Skills resolve as /spec, /spec-ready, /spec-go, /spec-complete,' +
279
+ ' /spec-cancel, /spec-bug, /spec-init, /spec-env, /spec-env-down.\n' +
280
+ 'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
281
+ " project's stack, then run /spec.\n" +
282
+ isolationNote,
283
+ )
284
+ }
285
+
286
+ async function init({ dir, force, claudeMd, mode, isolation }) {
287
+ if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
288
+ report.created.length = 0
289
+ report.updated.length = 0
290
+ report.skipped.length = 0
291
+ report.removed.length = 0
292
+ report.warnings.length = 0
293
+
294
+ installSkills(dir, { force })
295
+ installRule(dir, { force })
296
+ installFolders(dir)
297
+ removeRetiredFiles(dir)
298
+ installCore(dir, { force })
299
+ // Adopting isolation writes the live env.config.json — init only, never update.
300
+ if (mode !== 'update') installIsolation(dir, { enabled: isolation }, { force })
301
+ if (claudeMd) installClaudeMd(dir, { mode })
302
+
303
+ printReport(dir, mode)
304
+ }
305
+
306
+ module.exports = {
307
+ init,
308
+ SKILLS,
309
+ RULES,
310
+ SPEC_FOLDERS,
311
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,56 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Interactive setup flow for `skitterspec init`, built on the `prompts`
5
+ * (terkelg) library. Only required from the TTY branch of the CLI — the
6
+ * non-interactive path (flags / --yes / CI) never loads this module, so the
7
+ * test suite never imports the interactive UI.
8
+ *
9
+ * `isolationSeed` pre-fills the per-spec isolation question. Returns
10
+ * `{ isolation }`.
11
+ */
12
+
13
+ async function promptSetup({ isolationSeed = false } = {}) {
14
+ const prompts = require('prompts')
15
+
16
+ let cancelled = false
17
+ const onCancel = () => {
18
+ cancelled = true
19
+ return false // stop the prompt chain
20
+ }
21
+
22
+ const questions = [
23
+ {
24
+ type: 'confirm',
25
+ name: 'isolation',
26
+ message: 'Enable per-spec isolation — a git worktree per spec?',
27
+ initial: isolationSeed,
28
+ },
29
+ ]
30
+
31
+ const ans = await prompts(questions, { onCancel })
32
+ if (cancelled) throw new Error('Setup cancelled')
33
+
34
+ return { isolation: Boolean(ans.isolation) }
35
+ }
36
+
37
+ /**
38
+ * Interactive confirm for removing leftover release tooling on `update`. Returns
39
+ * true only on an explicit yes; a cancel (Ctrl-C / Esc) resolves to false so the
40
+ * default is always to keep the files.
41
+ */
42
+ async function confirmRemoveReleaseTooling() {
43
+ const prompts = require('prompts')
44
+ const ans = await prompts(
45
+ {
46
+ type: 'confirm',
47
+ name: 'remove',
48
+ message: 'Found release tooling (now in @skitterbyte/skittership). Remove it here?',
49
+ initial: false,
50
+ },
51
+ { onCancel: () => false },
52
+ )
53
+ return Boolean(ans.remove)
54
+ }
55
+
56
+ module.exports = { promptSetup, confirmRemoveReleaseTooling }