@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/LICENSE +21 -0
- package/README.md +164 -0
- package/assets/claude-md-section.md +34 -0
- package/assets/rules/commit-messages.md +85 -0
- package/assets/rules/spec-planning.md +130 -0
- package/assets/scripts/generate-changelog.js +274 -0
- package/assets/scripts/generate-releases.js +360 -0
- package/assets/scripts/lib/config.js +127 -0
- package/assets/scripts/lib/git-commits.js +265 -0
- package/assets/skills/commit/SKILL.md +28 -0
- package/assets/skills/spec/SKILL.md +176 -0
- package/assets/skills/spec-bug/SKILL.md +114 -0
- package/assets/skills/spec-cancel/SKILL.md +56 -0
- package/assets/skills/spec-complete/SKILL.md +60 -0
- package/assets/skills/spec-go/SKILL.md +87 -0
- package/assets/skills/spec-init/SKILL.md +92 -0
- package/assets/skills/spec-ready/SKILL.md +52 -0
- package/assets/skills/spec-review/SKILL.md +69 -0
- package/bin/skitterspec.js +9 -0
- package/package.json +42 -0
- package/src/cli.js +132 -0
- package/src/config.js +13 -0
- package/src/init.js +348 -0
- package/src/prompts.js +82 -0
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 }
|