@skitterbyte/skitterspec 0.1.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/src/cli.js ADDED
@@ -0,0 +1,132 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { init } = require('./init.js')
6
+ const { loadConfig } = require('./config.js')
7
+
8
+ const pkg = require('../package.json')
9
+
10
+ const HELP = `skitterspec — spec-driven-development for Claude Code
11
+
12
+ Usage:
13
+ skitterspec init [dir] Install skills, rule, specs/ folders, and (optionally)
14
+ changelog/release-note tooling into a project
15
+ skitterspec update [dir] Re-copy skills + rule + scripts (overwrites), leaves
16
+ specs/ and skitterspec.config.json alone
17
+ skitterspec --help Show this help
18
+ skitterspec --version Print version
19
+
20
+ Options (init / update):
21
+ --force Overwrite skill/rule/script files that already exist
22
+ --dir <path> Target project dir (default: positional arg or cwd)
23
+ --no-claude-md Skip creating/patching CLAUDE.md
24
+ --yes, -y Accept defaults; skip the interactive setup prompts
25
+
26
+ Release-tooling options (init) — drive setup non-interactively:
27
+ --changelog / --no-changelog Enable/disable CHANGELOG generation
28
+ --releases / --no-releases Enable/disable user-facing release notes
29
+ --changelog-file=NAME Changelog filename (default CHANGELOG.md)
30
+ --releases-file=NAME Release-notes filename (default RELEASES.md)
31
+ --product-name=NAME Product name shown in the release-notes header
32
+ --version-hook / --no-version-hook Wire (or skip) the npm "version" hook
33
+
34
+ Examples:
35
+ npx @skitterbyte/skitterspec init
36
+ npx @skitterbyte/skitterspec init ./my-app --yes
37
+ npx @skitterbyte/skitterspec init --no-releases --changelog-file=HISTORY.md
38
+ npx @skitterbyte/skitterspec update --force
39
+ `
40
+
41
+ function parse(argv) {
42
+ const opts = {
43
+ force: false,
44
+ claudeMd: true,
45
+ dir: null,
46
+ yes: false,
47
+ changelog: undefined,
48
+ releases: undefined,
49
+ changelogFile: undefined,
50
+ releasesFile: undefined,
51
+ productName: undefined,
52
+ versionHook: undefined,
53
+ }
54
+ const positional = []
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i]
57
+ if (a === '--force') opts.force = true
58
+ else if (a === '--no-claude-md') opts.claudeMd = false
59
+ else if (a === '--yes' || a === '-y') opts.yes = true
60
+ else if (a === '--changelog') opts.changelog = true
61
+ else if (a === '--no-changelog') opts.changelog = false
62
+ else if (a === '--releases') opts.releases = true
63
+ else if (a === '--no-releases') opts.releases = false
64
+ else if (a === '--version-hook') opts.versionHook = true
65
+ else if (a === '--no-version-hook') opts.versionHook = false
66
+ else if (a.startsWith('--changelog-file=')) opts.changelogFile = a.slice('--changelog-file='.length)
67
+ else if (a.startsWith('--releases-file=')) opts.releasesFile = a.slice('--releases-file='.length)
68
+ else if (a.startsWith('--product-name=')) opts.productName = a.slice('--product-name='.length)
69
+ else if (a === '--dir') opts.dir = argv[++i]
70
+ else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
71
+ else positional.push(a)
72
+ }
73
+ return { opts, positional }
74
+ }
75
+
76
+ // Resolve the release config: flags win, else the existing/default config.
77
+ // `existing` is a loaded skitterspec.config.json (loadConfig merges defaults).
78
+ function resolveRelease(existing, opts) {
79
+ const pick = (flag, fallback) => (flag === undefined ? fallback : flag)
80
+ return {
81
+ changelog: {
82
+ enabled: pick(opts.changelog, existing.changelog.enabled),
83
+ file: opts.changelogFile || existing.changelog.file,
84
+ },
85
+ releases: {
86
+ enabled: pick(opts.releases, existing.releases.enabled),
87
+ file: opts.releasesFile || existing.releases.file,
88
+ productName: opts.productName || existing.releases.productName,
89
+ scopeAreas: existing.releases.scopeAreas,
90
+ },
91
+ versionHook: pick(opts.versionHook, existing.versionHook),
92
+ }
93
+ }
94
+
95
+ async function run(argv) {
96
+ if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {
97
+ process.stdout.write(HELP)
98
+ return
99
+ }
100
+ if (argv.includes('--version') || argv.includes('-v')) {
101
+ process.stdout.write(`${pkg.version}\n`)
102
+ return
103
+ }
104
+
105
+ const [cmd, ...rest] = argv
106
+ const { opts, positional } = parse(rest)
107
+ const dir = path.resolve(opts.dir || positional[0] || process.cwd())
108
+
109
+ switch (cmd) {
110
+ case 'init': {
111
+ const existing = loadConfig(dir)
112
+ let release = resolveRelease(existing, opts)
113
+
114
+ const interactive = Boolean(process.stdin.isTTY) && !opts.yes
115
+ if (interactive) {
116
+ const { promptSetup } = require('./prompts.js')
117
+ const pkgExists = fs.existsSync(path.join(dir, 'package.json'))
118
+ release = await promptSetup({ seed: release, pkgExists })
119
+ }
120
+
121
+ await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', release })
122
+ break
123
+ }
124
+ case 'update':
125
+ await init({ dir, force: true, claudeMd: opts.claudeMd, mode: 'update' })
126
+ break
127
+ default:
128
+ throw new Error(`unknown command: ${cmd} (try --help)`)
129
+ }
130
+ }
131
+
132
+ module.exports = { run, parse, resolveRelease }
package/src/config.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Config helpers for the skitterspec CLI.
5
+ *
6
+ * The implementation lives in `assets/scripts/lib/config.js` — the same file
7
+ * that ships into a consumer's `scripts/lib/`, so the loader has one source of
8
+ * truth and the consumer's copied scripts never depend back into this package.
9
+ * This module just re-exports it for use by the CLI (`init`, the install
10
+ * prompts in Phase 3).
11
+ */
12
+
13
+ module.exports = require('../assets/scripts/lib/config.js')
package/src/init.js ADDED
@@ -0,0 +1,348 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const { loadConfig, SCHEMA_VERSION } = require('./config.js')
7
+
8
+ const ASSETS = path.join(__dirname, '..', 'assets')
9
+
10
+ // Shipped generator scripts, copied into the consumer's scripts/ when enabled.
11
+ const SHARED_LIB = [
12
+ path.join('scripts', 'lib', 'git-commits.js'),
13
+ path.join('scripts', 'lib', 'config.js'),
14
+ ]
15
+ const CHANGELOG_SCRIPT = path.join('scripts', 'generate-changelog.js')
16
+ const RELEASES_SCRIPT = path.join('scripts', 'generate-releases.js')
17
+ const CONFIG_FILE = 'skitterspec.config.json'
18
+
19
+ const SKILLS = [
20
+ 'spec',
21
+ 'spec-bug',
22
+ 'spec-ready',
23
+ 'spec-review',
24
+ 'spec-go',
25
+ 'spec-complete',
26
+ 'spec-cancel',
27
+ 'spec-init',
28
+ 'commit',
29
+ ]
30
+
31
+ const RULES = ['spec-planning.md', 'commit-messages.md']
32
+
33
+ const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled']
34
+
35
+ const BACKLOG_INDEX = `<!-- Maintained by the spec skills — do not hand-edit. -->
36
+ <!-- Live view of the backlog. /spec prepends a row; /spec-ready updates status; -->
37
+ <!-- /spec-go and /spec-cancel remove the row when the spec leaves the backlog. -->
38
+
39
+ | Added | Spec | Type | Status |
40
+ |-------|------|------|--------|
41
+ `
42
+
43
+ const COMPLETE_INDEX = `<!-- Maintained by the spec skills — do not hand-edit. -->
44
+ <!-- Append-only completion log, newest first. /spec-complete prepends a row. -->
45
+
46
+ | Completed | Spec | Type |
47
+ |-----------|------|------|
48
+ `
49
+
50
+ const SPEC_MARKER_START = '<!-- skitterspec:start -->'
51
+ const SPEC_MARKER_END = '<!-- skitterspec:end -->'
52
+
53
+ const report = { created: [], updated: [], skipped: [], warnings: [] }
54
+
55
+ function rel(dir, p) {
56
+ return path.relative(dir, p) || '.'
57
+ }
58
+
59
+ function ensureDir(p) {
60
+ if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true })
61
+ }
62
+
63
+ function writeFile(dir, target, content, { force }) {
64
+ if (fs.existsSync(target)) {
65
+ if (!force) {
66
+ report.skipped.push(rel(dir, target))
67
+ return
68
+ }
69
+ const existing = fs.readFileSync(target, 'utf8')
70
+ if (existing === content) {
71
+ report.skipped.push(rel(dir, target))
72
+ return
73
+ }
74
+ fs.writeFileSync(target, content)
75
+ report.updated.push(rel(dir, target))
76
+ return
77
+ }
78
+ ensureDir(path.dirname(target))
79
+ fs.writeFileSync(target, content)
80
+ report.created.push(rel(dir, target))
81
+ }
82
+
83
+ function copyAsset(dir, assetRelPath, targetAbs, opts) {
84
+ const content = fs.readFileSync(path.join(ASSETS, assetRelPath), 'utf8')
85
+ writeFile(dir, targetAbs, content, opts)
86
+ }
87
+
88
+ function installSkills(dir, opts) {
89
+ for (const name of SKILLS) {
90
+ copyAsset(
91
+ dir,
92
+ path.join('skills', name, 'SKILL.md'),
93
+ path.join(dir, '.claude', 'skills', name, 'SKILL.md'),
94
+ opts,
95
+ )
96
+ }
97
+ }
98
+
99
+ function installRule(dir, opts) {
100
+ for (const name of RULES) {
101
+ copyAsset(
102
+ dir,
103
+ path.join('rules', name),
104
+ path.join(dir, '.claude', 'rules', name),
105
+ opts,
106
+ )
107
+ }
108
+ }
109
+
110
+ // backlog + complete are kept in git by their 00-index.md file, so they need no .gitkeep
111
+ const FOLDERS_WITH_INDEX = new Set(['backlog', 'complete'])
112
+
113
+ function installFolders(dir) {
114
+ for (const folder of SPEC_FOLDERS) {
115
+ const abs = path.join(dir, 'specs', folder)
116
+ if (!fs.existsSync(abs)) {
117
+ ensureDir(abs)
118
+ report.created.push(rel(dir, abs) + '/')
119
+ // keep otherwise-empty folders in git (those without an 00-index.md)
120
+ if (!FOLDERS_WITH_INDEX.has(folder) && !fs.readdirSync(abs).length) {
121
+ fs.writeFileSync(path.join(abs, '.gitkeep'), '')
122
+ }
123
+ } else {
124
+ report.skipped.push(rel(dir, abs) + '/')
125
+ }
126
+ }
127
+ }
128
+
129
+ function installIndexes(dir, opts) {
130
+ writeFile(dir, path.join(dir, 'specs', 'backlog', '00-index.md'), BACKLOG_INDEX, opts)
131
+ writeFile(dir, path.join(dir, 'specs', 'complete', '00-index.md'), COMPLETE_INDEX, opts)
132
+ }
133
+
134
+ function installClaudeMd(dir, { mode }) {
135
+ const section = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
136
+ const block = `${SPEC_MARKER_START}\n${section}\n${SPEC_MARKER_END}\n`
137
+ const target = path.join(dir, 'CLAUDE.md')
138
+
139
+ if (!fs.existsSync(target)) {
140
+ fs.writeFileSync(target, `# ${path.basename(dir)}\n\n${block}`)
141
+ report.created.push('CLAUDE.md')
142
+ return
143
+ }
144
+
145
+ const existing = fs.readFileSync(target, 'utf8')
146
+
147
+ if (existing.includes(SPEC_MARKER_START) && existing.includes(SPEC_MARKER_END)) {
148
+ if (mode !== 'update') {
149
+ report.skipped.push('CLAUDE.md (spec workflow already present)')
150
+ return
151
+ }
152
+ const re = new RegExp(`${SPEC_MARKER_START}[\\s\\S]*?${SPEC_MARKER_END}\\n?`)
153
+ const next = existing.replace(re, block)
154
+ if (next === existing) {
155
+ report.skipped.push('CLAUDE.md')
156
+ } else {
157
+ fs.writeFileSync(target, next)
158
+ report.updated.push('CLAUDE.md (spec workflow section)')
159
+ }
160
+ return
161
+ }
162
+
163
+ if (/^##\s+Spec workflow/m.test(existing)) {
164
+ report.skipped.push('CLAUDE.md (has a manual "Spec workflow" section — left alone)')
165
+ return
166
+ }
167
+
168
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n'
169
+ fs.writeFileSync(target, `${existing}${sep}${block}`)
170
+ report.updated.push('CLAUDE.md (appended spec workflow section)')
171
+ }
172
+
173
+ // --- release tooling (changelog / release notes) ---------------------------
174
+
175
+ // Build a release-config object from a loaded skitterspec.config.json.
176
+ function releaseFromConfig(cfg) {
177
+ return {
178
+ changelog: { enabled: cfg.changelog.enabled, file: cfg.changelog.file },
179
+ releases: {
180
+ enabled: cfg.releases.enabled,
181
+ file: cfg.releases.file,
182
+ productName: cfg.releases.productName,
183
+ scopeAreas: cfg.releases.scopeAreas,
184
+ },
185
+ versionHook: cfg.versionHook,
186
+ }
187
+ }
188
+
189
+ function serializeConfig(release) {
190
+ return (
191
+ JSON.stringify(
192
+ {
193
+ version: SCHEMA_VERSION,
194
+ changelog: release.changelog,
195
+ releases: release.releases,
196
+ versionHook: release.versionHook,
197
+ },
198
+ null,
199
+ 2,
200
+ ) + '\n'
201
+ )
202
+ }
203
+
204
+ // Write the resolved config. The release object already folds in any existing
205
+ // file (the CLI seeds it from loadConfig), so this is a merge, not a clobber —
206
+ // safe to persist without --force. Unchanged content is left alone.
207
+ function writeConfig(dir, release) {
208
+ const target = path.join(dir, CONFIG_FILE)
209
+ const content = serializeConfig(release)
210
+ const exists = fs.existsSync(target)
211
+ if (exists && fs.readFileSync(target, 'utf8') === content) {
212
+ report.skipped.push(CONFIG_FILE)
213
+ return
214
+ }
215
+ fs.writeFileSync(target, content)
216
+ report[exists ? 'updated' : 'created'].push(CONFIG_FILE)
217
+ }
218
+
219
+ function installScripts(dir, release, opts) {
220
+ if (!release.changelog.enabled && !release.releases.enabled) return
221
+ for (const lib of SHARED_LIB) {
222
+ copyAsset(dir, lib, path.join(dir, lib), opts)
223
+ }
224
+ if (release.changelog.enabled) {
225
+ copyAsset(dir, CHANGELOG_SCRIPT, path.join(dir, CHANGELOG_SCRIPT), opts)
226
+ }
227
+ if (release.releases.enabled) {
228
+ copyAsset(dir, RELEASES_SCRIPT, path.join(dir, RELEASES_SCRIPT), opts)
229
+ }
230
+ }
231
+
232
+ // Idempotently add the npm scripts that drive generation at `npm version`.
233
+ // Never overwrites a user's custom `version` script without --force.
234
+ function wireVersionHook(dir, release, { force }) {
235
+ const pkgPath = path.join(dir, 'package.json')
236
+ if (!fs.existsSync(pkgPath)) {
237
+ report.skipped.push('version hook (no package.json)')
238
+ return
239
+ }
240
+
241
+ let pkg
242
+ try {
243
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
244
+ } catch {
245
+ report.warnings.push('package.json is not valid JSON — skipped version hook wiring')
246
+ return
247
+ }
248
+
249
+ const genCmds = []
250
+ const addFiles = []
251
+ if (release.changelog.enabled) {
252
+ genCmds.push('node scripts/generate-changelog.js')
253
+ addFiles.push(release.changelog.file)
254
+ }
255
+ if (release.releases.enabled) {
256
+ genCmds.push('node scripts/generate-releases.js')
257
+ addFiles.push(release.releases.file)
258
+ }
259
+ if (genCmds.length === 0) return
260
+
261
+ const versionCmd = [...genCmds, `git add ${addFiles.join(' ')}`].join(' && ')
262
+
263
+ const before = JSON.stringify(pkg)
264
+ pkg.scripts = pkg.scripts || {}
265
+
266
+ if (pkg.scripts.version && pkg.scripts.version !== versionCmd && !force) {
267
+ report.warnings.push(
268
+ 'Kept your existing "version" npm script. To regenerate on release, add:\n' +
269
+ ` "version": "${versionCmd}" (or re-run with --force)`,
270
+ )
271
+ } else {
272
+ pkg.scripts.version = versionCmd
273
+ }
274
+
275
+ const helpers = {}
276
+ if (release.changelog.enabled) {
277
+ helpers.changelog = 'node scripts/generate-changelog.js'
278
+ helpers['changelog:retro'] = 'node scripts/generate-changelog.js --retro'
279
+ }
280
+ if (release.releases.enabled) {
281
+ helpers.releases = 'node scripts/generate-releases.js'
282
+ helpers['releases:retro'] = 'node scripts/generate-releases.js --retro'
283
+ }
284
+ for (const [name, cmd] of Object.entries(helpers)) {
285
+ if (pkg.scripts[name] && pkg.scripts[name] !== cmd && !force) continue
286
+ pkg.scripts[name] = cmd
287
+ }
288
+
289
+ if (JSON.stringify(pkg) !== before) {
290
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
291
+ report.updated.push('package.json (version hook + scripts)')
292
+ } else {
293
+ report.skipped.push('package.json (version hook already present)')
294
+ }
295
+ }
296
+
297
+ function printReport(dir, mode) {
298
+ const line = (label, items) => {
299
+ if (!items.length) return
300
+ process.stdout.write(`\n${label}:\n`)
301
+ for (const it of items) process.stdout.write(` ${it}\n`)
302
+ }
303
+ process.stdout.write(`\nskitterspec ${mode} → ${dir}\n`)
304
+ line('created', report.created)
305
+ line('updated', report.updated)
306
+ line('unchanged', report.skipped)
307
+ if (report.warnings.length) {
308
+ process.stdout.write('\nwarnings:\n')
309
+ for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
310
+ }
311
+ process.stdout.write(
312
+ '\nDone. Skills resolve as /spec, /spec-ready, /spec-go, /spec-complete,' +
313
+ ' /spec-cancel, /spec-bug, /spec-init, /commit.\n' +
314
+ 'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
315
+ " project's stack, then run /spec.\n",
316
+ )
317
+ }
318
+
319
+ async function init({ dir, force, claudeMd, mode, release }) {
320
+ if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
321
+ report.created.length = 0
322
+ report.updated.length = 0
323
+ report.skipped.length = 0
324
+ report.warnings.length = 0
325
+
326
+ installSkills(dir, { force })
327
+ installRule(dir, { force })
328
+ installFolders(dir)
329
+ installIndexes(dir, { force })
330
+ if (claudeMd) installClaudeMd(dir, { mode })
331
+
332
+ // Release tooling. The CLI resolves `release` from flags/prompts; when called
333
+ // directly (e.g. tests, update) fall back to the on-disk/default config.
334
+ const rel = release || releaseFromConfig(loadConfig(dir))
335
+ if (mode !== 'update') writeConfig(dir, rel)
336
+ installScripts(dir, rel, { force })
337
+ if (mode !== 'update' && rel.versionHook) wireVersionHook(dir, rel, { force })
338
+
339
+ printReport(dir, mode)
340
+ }
341
+
342
+ module.exports = {
343
+ init,
344
+ SKILLS,
345
+ RULES,
346
+ SPEC_FOLDERS,
347
+ releaseFromConfig,
348
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,82 @@
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
+ * `seed` is the resolved release config (existing file merged with any flags),
10
+ * used to pre-fill every answer. Returns a release config of the same shape.
11
+ */
12
+
13
+ async function promptSetup({ seed, pkgExists }) {
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: 'changelogEnabled',
26
+ message: 'Generate a dev-facing CHANGELOG from commit subjects?',
27
+ initial: seed.changelog.enabled,
28
+ },
29
+ {
30
+ type: (prev) => (prev ? 'text' : null),
31
+ name: 'changelogFile',
32
+ message: 'Changelog filename',
33
+ initial: seed.changelog.file,
34
+ },
35
+ {
36
+ type: 'confirm',
37
+ name: 'releasesEnabled',
38
+ message: 'Generate user-facing release notes from Release-Note: footers?',
39
+ initial: seed.releases.enabled,
40
+ },
41
+ {
42
+ type: (_prev, values) => (values.releasesEnabled ? 'text' : null),
43
+ name: 'releasesFile',
44
+ message: 'Release-notes filename',
45
+ initial: seed.releases.file,
46
+ },
47
+ {
48
+ type: (_prev, values) => (values.releasesEnabled ? 'text' : null),
49
+ name: 'productName',
50
+ message: 'Product name (shown in the release-notes header)',
51
+ initial: seed.releases.productName,
52
+ },
53
+ ]
54
+
55
+ if (pkgExists) {
56
+ questions.push({
57
+ type: 'confirm',
58
+ name: 'versionHook',
59
+ message: 'Wire an npm "version" hook to regenerate these on release?',
60
+ initial: seed.versionHook,
61
+ })
62
+ }
63
+
64
+ const ans = await prompts(questions, { onCancel })
65
+ if (cancelled) throw new Error('Setup cancelled')
66
+
67
+ return {
68
+ changelog: {
69
+ enabled: ans.changelogEnabled,
70
+ file: ans.changelogFile || seed.changelog.file,
71
+ },
72
+ releases: {
73
+ enabled: ans.releasesEnabled,
74
+ file: ans.releasesFile || seed.releases.file,
75
+ productName: ans.productName || seed.releases.productName,
76
+ scopeAreas: seed.releases.scopeAreas,
77
+ },
78
+ versionHook: pkgExists ? Boolean(ans.versionHook) : seed.versionHook,
79
+ }
80
+ }
81
+
82
+ module.exports = { promptSetup }