@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.
- package/README.md +56 -0
- package/assets/claude-md-section.md +39 -0
- package/assets/core/env.config.json.example +28 -0
- package/assets/core/env.config.md +99 -0
- package/assets/core/linear.config.json.example +39 -0
- package/assets/core/linear.config.md +121 -0
- package/assets/rules/spec-planning.md +152 -0
- package/assets/skills/spec/SKILL.md +232 -0
- package/assets/skills/spec-bug/SKILL.md +110 -0
- package/assets/skills/spec-cancel/SKILL.md +61 -0
- package/assets/skills/spec-complete/SKILL.md +87 -0
- package/assets/skills/spec-env/SKILL.md +63 -0
- package/assets/skills/spec-env-down/SKILL.md +64 -0
- package/assets/skills/spec-go/SKILL.md +134 -0
- package/assets/skills/spec-init/SKILL.md +84 -0
- package/assets/skills/spec-pull/SKILL.md +46 -0
- package/assets/skills/spec-push/SKILL.md +53 -0
- package/assets/skills/spec-ready/SKILL.md +50 -0
- package/assets/skills/spec-review/SKILL.md +69 -0
- package/assets/skills/spec-status/SKILL.md +46 -0
- package/bin/skitterspec-linear.js +26 -0
- package/package.json +38 -0
- package/src/cli.js +495 -0
- package/src/deprecate.js +138 -0
- package/src/env/config.js +165 -0
- package/src/env/integrate.js +46 -0
- package/src/env/provision.js +76 -0
- package/src/env/registry.js +95 -0
- package/src/env/render.js +26 -0
- package/src/env/resolve.js +202 -0
- package/src/env/teardown.js +109 -0
- package/src/env/trust.js +87 -0
- package/src/init.js +311 -0
- package/src/prompts.js +56 -0
- package/src/vendor/linear/cli-sync.js +256 -0
- package/src/vendor/linear/config.js +198 -0
- package/src/vendor/linear/mcp.js +112 -0
- package/src/vendor/sync-core/index.js +35 -0
- package/src/vendor/sync-core/src/apply.js +66 -0
- package/src/vendor/sync-core/src/base.js +83 -0
- package/src/vendor/sync-core/src/compare.js +99 -0
- package/src/vendor/sync-core/src/normalize.js +249 -0
- package/src/vendor/sync-core/src/pull.js +84 -0
- package/src/vendor/sync-core/src/push.js +106 -0
- 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 }
|
package/src/env/trust.js
ADDED
|
@@ -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 }
|