@skitterbyte/skitterspec 1.0.0 → 2.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 (42) hide show
  1. package/README.md +27 -244
  2. package/assets/claude-md-section.md +0 -6
  3. package/assets/core/env.config.json.example +5 -1
  4. package/assets/core/env.config.md +21 -5
  5. package/assets/rules/spec-planning.md +14 -10
  6. package/assets/skills/spec/SKILL.md +11 -38
  7. package/assets/skills/spec-complete/SKILL.md +31 -4
  8. package/assets/skills/spec-env/SKILL.md +6 -0
  9. package/assets/skills/spec-env-down/SKILL.md +16 -8
  10. package/assets/skills/spec-go/SKILL.md +15 -17
  11. package/package.json +6 -11
  12. package/src/cli.js +174 -318
  13. package/src/deprecate.js +138 -0
  14. package/src/env/config.js +17 -4
  15. package/src/env/integrate.js +46 -0
  16. package/src/env/resolve.js +54 -45
  17. package/src/env/teardown.js +19 -4
  18. package/src/env/trust.js +87 -0
  19. package/src/init.js +78 -170
  20. package/src/prompts.js +26 -63
  21. package/LICENSE +0 -21
  22. package/assets/core/linear.config.json.example +0 -39
  23. package/assets/core/linear.config.md +0 -121
  24. package/assets/rules/commit-messages.md +0 -85
  25. package/assets/scripts/generate-changelog.js +0 -274
  26. package/assets/scripts/generate-releases.js +0 -360
  27. package/assets/scripts/lib/config.js +0 -127
  28. package/assets/scripts/lib/git-commits.js +0 -265
  29. package/assets/skills/commit/SKILL.md +0 -28
  30. package/assets/skills/spec-pull/SKILL.md +0 -46
  31. package/assets/skills/spec-push/SKILL.md +0 -53
  32. package/assets/skills/spec-status/SKILL.md +0 -46
  33. package/src/config.js +0 -13
  34. package/src/sync/apply.js +0 -66
  35. package/src/sync/base.js +0 -83
  36. package/src/sync/compare.js +0 -99
  37. package/src/sync/config.js +0 -198
  38. package/src/sync/mcp.js +0 -112
  39. package/src/sync/normalize.js +0 -249
  40. package/src/sync/pull.js +0 -84
  41. package/src/sync/push.js +0 -106
  42. package/src/sync/write.js +0 -86
@@ -0,0 +1,138 @@
1
+ 'use strict'
2
+
3
+ // Cleanup path for projects that installed release tooling from an older
4
+ // skitterspec (before it moved to @skitterbyte/skittership). `skitterspec update`
5
+ // detects the leftover files and — only on an explicit interactive "yes" or the
6
+ // --remove-release-tooling flag — removes exactly what skitterspec used to
7
+ // install. It never touches the user's generated CHANGELOG.md / RELEASES.md, nor
8
+ // any script it didn't add.
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ const SKITTERSHIP = '@skitterbyte/skittership'
14
+
15
+ // Files/dirs skitterspec used to install for release tooling (repo-relative).
16
+ const RELEASE_PATHS = [
17
+ 'skitterspec.config.json',
18
+ path.join('scripts', 'generate-changelog.js'),
19
+ path.join('scripts', 'generate-releases.js'),
20
+ path.join('scripts', 'lib', 'git-commits.js'),
21
+ path.join('scripts', 'lib', 'config.js'),
22
+ path.join('.claude', 'skills', 'commit'),
23
+ path.join('.claude', 'rules', 'commit-messages.md'),
24
+ ]
25
+
26
+ // npm scripts skitterspec used to wire; only removed when their value still
27
+ // matches the generator command (so a user's custom override is preserved).
28
+ const HELPER_SCRIPTS = {
29
+ changelog: 'node scripts/generate-changelog.js',
30
+ 'changelog:retro': 'node scripts/generate-changelog.js --retro',
31
+ releases: 'node scripts/generate-releases.js',
32
+ 'releases:retro': 'node scripts/generate-releases.js --retro',
33
+ }
34
+
35
+ function readPkg(dir) {
36
+ const pkgPath = path.join(dir, 'package.json')
37
+ if (!fs.existsSync(pkgPath)) return null
38
+ try {
39
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ // Does package.json have a `version` script that runs the release generators?
46
+ function versionHookReferencesGenerators(pkg) {
47
+ const v = pkg && pkg.scripts && pkg.scripts.version
48
+ return typeof v === 'string' && /generate-(changelog|releases)\.js/.test(v)
49
+ }
50
+
51
+ // Is skittership the source of the release tooling here (rather than a leftover
52
+ // legacy skitterspec install)? True when the project has adopted skittership —
53
+ // its config file is present, or it's a declared dependency. In that case the
54
+ // release files are skittership's current install and must NOT be offered for
55
+ // removal (they'd just come back on the next `skittership init`).
56
+ function skittershipAdopted(dir) {
57
+ if (fs.existsSync(path.join(dir, 'skittership.config.json'))) return true
58
+ const pkg = readPkg(dir)
59
+ const deps = Object.assign({}, pkg && pkg.dependencies, pkg && pkg.devDependencies)
60
+ return Boolean(deps['@skitterbyte/skittership'])
61
+ }
62
+
63
+ // Report which release-tooling artifacts are present. `present` is true only when
64
+ // there are legacy artifacts to clean up (a file/dir or a generator-driven version
65
+ // hook) AND skittership hasn't been adopted — otherwise the files belong to a
66
+ // live skittership install, not an old bundled-skitterspec one.
67
+ function detectReleaseTooling(dir) {
68
+ const files = RELEASE_PATHS.filter((rel) => fs.existsSync(path.join(dir, rel)))
69
+ const pkg = readPkg(dir)
70
+ const versionHook = versionHookReferencesGenerators(pkg)
71
+ const adopted = skittershipAdopted(dir)
72
+ return { present: (files.length > 0 || versionHook) && !adopted, files, versionHook, adopted }
73
+ }
74
+
75
+ // Remove an emptied directory, walking up while parents are left empty. Never
76
+ // climbs out of `dir`.
77
+ function pruneEmptyDirs(dir, startAbs) {
78
+ let cur = startAbs
79
+ while (cur.startsWith(dir) && cur !== dir && fs.existsSync(cur)) {
80
+ if (fs.readdirSync(cur).length > 0) break
81
+ fs.rmdirSync(cur)
82
+ cur = path.dirname(cur)
83
+ }
84
+ }
85
+
86
+ // Remove exactly the detected artifacts + unwire the version hook. Returns a
87
+ // report of what was removed. Scoped and non-destructive: only skitterspec's own
88
+ // files, and only npm scripts whose value still matches the generator command.
89
+ function removeReleaseTooling(dir, detection = detectReleaseTooling(dir)) {
90
+ const removed = []
91
+
92
+ for (const rel of detection.files) {
93
+ const abs = path.join(dir, rel)
94
+ if (!fs.existsSync(abs)) continue
95
+ fs.rmSync(abs, { recursive: true, force: true })
96
+ removed.push(rel)
97
+ pruneEmptyDirs(dir, path.dirname(abs))
98
+ }
99
+
100
+ const pkg = readPkg(dir)
101
+ if (pkg && pkg.scripts) {
102
+ let changed = false
103
+ if (versionHookReferencesGenerators(pkg)) {
104
+ delete pkg.scripts.version
105
+ removed.push('package.json (version hook)')
106
+ changed = true
107
+ }
108
+ for (const [name, cmd] of Object.entries(HELPER_SCRIPTS)) {
109
+ if (pkg.scripts[name] === cmd) {
110
+ delete pkg.scripts[name]
111
+ changed = true
112
+ }
113
+ }
114
+ if (changed) {
115
+ if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts
116
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
117
+ }
118
+ }
119
+
120
+ return { removed }
121
+ }
122
+
123
+ // One-line pointer shown when we detect release tooling but don't remove it
124
+ // (declined, or a non-interactive run).
125
+ function releaseToolingNotice() {
126
+ return (
127
+ `Release tooling has moved to ${SKITTERSHIP} — run ` +
128
+ `npx ${SKITTERSHIP} init to keep it (your config is migrated automatically).`
129
+ )
130
+ }
131
+
132
+ module.exports = {
133
+ detectReleaseTooling,
134
+ removeReleaseTooling,
135
+ releaseToolingNotice,
136
+ RELEASE_PATHS,
137
+ SKITTERSHIP,
138
+ }
package/src/env/config.js CHANGED
@@ -18,7 +18,8 @@
18
18
  * portsPerSpec, envFile, backupCommand },
19
19
  * open: { command }, // optional, editor/terminal-agnostic opener
20
20
  * registry: ".spec-env/registry.json",
21
- * linkLinear: true,
21
+ * branch: { pattern, identifierField }, // git branch naming (provider-neutral)
22
+ * baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
22
23
  * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed }
23
24
  * }
24
25
  */
@@ -41,7 +42,13 @@ const DEFAULT_CONFIG = Object.freeze({
41
42
  }),
42
43
  open: Object.freeze({ command: '' }),
43
44
  registry: '.spec-env/registry.json',
44
- linkLinear: true,
45
+ // Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
46
+ // when a tracker provider is linked, {identifier}; `identifierField` names the
47
+ // 00-overview.md frontmatter field a provider writes the ticket id into (empty
48
+ // = no identifier, so patterns referencing {identifier} fall back to type/slug).
49
+ branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
50
+ // Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
51
+ baseBranch: '',
45
52
  guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
46
53
  })
47
54
 
@@ -56,7 +63,8 @@ function defaults() {
56
63
  docker: { ...DEFAULT_CONFIG.docker },
57
64
  open: { ...DEFAULT_CONFIG.open },
58
65
  registry: DEFAULT_CONFIG.registry,
59
- linkLinear: DEFAULT_CONFIG.linkLinear,
66
+ branch: { ...DEFAULT_CONFIG.branch },
67
+ baseBranch: DEFAULT_CONFIG.baseBranch,
60
68
  guards: { ...DEFAULT_CONFIG.guards },
61
69
  }
62
70
  }
@@ -104,8 +112,13 @@ function mergeConfig(base, parsed) {
104
112
  assign(base.open, parsed.open, 'command', 'string?')
105
113
  }
106
114
 
115
+ if (isObject(parsed.branch)) {
116
+ assign(base.branch, parsed.branch, 'pattern', 'string')
117
+ assign(base.branch, parsed.branch, 'identifierField', 'string')
118
+ }
119
+
107
120
  assign(base, parsed, 'registry', 'string')
108
- assign(base, parsed, 'linkLinear', 'boolean')
121
+ assign(base, parsed, 'baseBranch', 'string')
109
122
 
110
123
  if (isObject(parsed.guards)) {
111
124
  assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
@@ -0,0 +1,46 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure integrate planner for `spec-env integrate`.
5
+ *
6
+ * `planIntegrate` decides whether a spec's worktree branch can be landed onto the
7
+ * base branch and, if so, emits the exact commands the `/spec-complete` skill runs
8
+ * (rebase the branch onto base in the worktree, then fast-forward base to it in the
9
+ * primary checkout). It performs no side effects: the caller (the CLI) queries git
10
+ * for `dirty`/`aheadOfBase`/`mainRepoPath` and supplies them, keeping this
11
+ * deterministic and unit-testable with no live git.
12
+ *
13
+ * Strategy is rebase + fast-forward (linear history). Conflict handling lives in
14
+ * the skill: it runs the rebase and, on a non-zero exit, `git rebase --abort` and
15
+ * hands back — so the planner never needs to reason about conflicts.
16
+ *
17
+ * @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
18
+ * @param {object} config normalised env config (unused today; kept for symmetry).
19
+ * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
20
+ * @returns {object} { blocked, noop, reason, commands, base, branch }
21
+ */
22
+ function planIntegrate(spec, config, ctx) {
23
+ const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
24
+ const branch = spec.branch
25
+ const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
26
+
27
+ // The completion edits must be committed first — never rebase a dirty tree.
28
+ if (worktreeState.dirty) {
29
+ return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
30
+ }
31
+
32
+ // Nothing on the branch that isn't already on base → already landed.
33
+ if (!aheadOfBase) {
34
+ return { ...result, noop: true }
35
+ }
36
+
37
+ return {
38
+ ...result,
39
+ commands: [
40
+ `git -C ${spec.worktreePath} rebase ${base}`,
41
+ `git -C ${mainRepoPath} merge --ff-only ${branch}`,
42
+ ],
43
+ }
44
+ }
45
+
46
+ module.exports = { planIntegrate }
@@ -5,21 +5,18 @@
5
5
  *
6
6
  * Given a spec argument (a folder name or path) it locates the spec folder under
7
7
  * `specs/**`, splits the `feat-`/`bug-` prefix into `{ type, slug }`, derives the
8
- * git branch (optional Linear seam, else `{type}/{slug}`), and expands the
9
- * config's path/name tokens (`{repo}`, `{repoSlug}`, `{slug}`). Reads files to
10
- * locate the spec and read frontmatter, but makes no git/docker side effects —
11
- * deterministic and safe to unit-test with fixtures.
8
+ * git branch from the config's `branch.pattern` (provider-neutral; `{identifier}`
9
+ * is filled from a tracker id when one is configured, else it falls back to
10
+ * `{type}/{slug}`), and expands the config's path/name tokens (`{repo}`,
11
+ * `{repoSlug}`, `{slug}`). Reads files to locate the spec and read frontmatter,
12
+ * but makes no git/docker side effects — deterministic and safe to unit-test with
13
+ * fixtures.
12
14
  */
13
15
 
14
16
  const fs = require('node:fs')
15
17
  const path = require('node:path')
16
18
 
17
19
  const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
18
- const LINEAR_CONFIG = path.join('specs', '.core', 'linear.config.json')
19
-
20
- function isObject(value) {
21
- return value !== null && typeof value === 'object' && !Array.isArray(value)
22
- }
23
20
 
24
21
  // Find the spec folder under specs/<bucket>/<name>. `specArg` may be a bare
25
22
  // folder name or a path — only its basename is matched against the buckets.
@@ -59,9 +56,12 @@ function expandTokens(str, tokens) {
59
56
  )
60
57
  }
61
58
 
62
- // Read the `linear_identifier` from a spec's 00-overview.md YAML frontmatter,
63
- // if present. Returns null when there's no frontmatter / field / file.
64
- function readLinearIdentifier(specPath) {
59
+ // Read a named field from a spec's 00-overview.md YAML frontmatter, if present.
60
+ // `field` is provider-neutral (e.g. a tracker's ticket-id field, configured via
61
+ // `branch.identifierField`). Returns null when there's no frontmatter / field /
62
+ // file, or no field name was given.
63
+ function readFrontmatterField(specPath, field) {
64
+ if (!field) return null
65
65
  const overview = path.join(specPath, '00-overview.md')
66
66
  let raw
67
67
  try {
@@ -71,7 +71,7 @@ function readLinearIdentifier(specPath) {
71
71
  }
72
72
  const fm = /^---\n([\s\S]*?)\n---/.exec(raw)
73
73
  if (!fm) return null
74
- const m = /^linear_identifier:\s*(.+)$/m.exec(fm[1])
74
+ const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(fm[1])
75
75
  if (!m) return null
76
76
  return m[1].trim().replace(/^["']|["']$/g, '') || null
77
77
  }
@@ -99,43 +99,51 @@ function readStackField(specPath, config) {
99
99
  return config.docker && config.docker.enabled ? 'docker' : 'worktree'
100
100
  }
101
101
 
102
- // Load specs/.core/linear.config.json when present, else null.
103
- function loadLinearConfig(dir) {
104
- const file = path.join(dir, LINEAR_CONFIG)
105
- let raw
106
- try {
107
- raw = fs.readFileSync(file, 'utf-8')
108
- } catch {
109
- return null
110
- }
111
- try {
112
- const parsed = JSON.parse(raw)
113
- return isObject(parsed) ? parsed : null
114
- } catch (error) {
115
- throw new Error(`Invalid ${LINEAR_CONFIG}: ${error.message}`)
102
+ /**
103
+ * Derive the git branch for a spec from the provider-neutral `branch.pattern`
104
+ * (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
105
+ * `{identifier}`, the id is read from the frontmatter field named by
106
+ * `branch.identifierField` (a tracker provider writes it); if that field is unset
107
+ * or absent on the spec, the branch falls back to `{type}/{slug}` so we never
108
+ * emit a half-expanded name. No knowledge of any specific tracker lives here.
109
+ */
110
+ function branchFor(spec, config) {
111
+ const branch = (config.branch && config.branch.pattern) || '{type}/{slug}'
112
+ const tokens = { type: spec.type, slug: spec.slug }
113
+ if (/\{identifier\}/.test(branch)) {
114
+ const field = config.branch && config.branch.identifierField
115
+ const identifier = readFrontmatterField(spec.path, field)
116
+ if (!identifier) return `${spec.type}/${spec.slug}`
117
+ tokens.identifier = identifier
116
118
  }
119
+ return expandTokens(branch, tokens)
117
120
  }
118
121
 
119
122
  /**
120
- * Derive the git branch for a spec. If `linkLinear` and a linear.config.json
121
- * with a `branch.pattern` is present and the spec has a `linear_identifier`,
122
- * expand that pattern (`{identifier}`, `{slug}`, `{type}`). Otherwise fall back
123
- * to `{type}/{slug}`.
123
+ * Resolve the integration base branch (the branch specs fork from and land back
124
+ * onto). Precedence:
125
+ * 1. `config.baseBranch` explicit override
126
+ * 2. `origin/HEAD` — the remote's default branch
127
+ * 3. `main` if it exists locally
128
+ * 4. `master` if it exists locally
129
+ * 5. `main` — last-resort default
130
+ *
131
+ * `git(args)` runs a read-only git command and returns trimmed stdout, or `null`
132
+ * on a non-zero exit / failure. It's injected so this stays pure and unit-testable
133
+ * with no live git; the CLI supplies a real reader. (Note: `show-ref --quiet`
134
+ * emits no stdout on success, so a non-null `''` still means "exists".)
124
135
  */
125
- function branchFor(spec, dir, config) {
126
- if (config.linkLinear) {
127
- const linear = loadLinearConfig(dir)
128
- const pattern = linear && linear.branch && linear.branch.pattern
129
- const identifier = readLinearIdentifier(spec.path)
130
- if (pattern && identifier) {
131
- return expandTokens(pattern, {
132
- identifier,
133
- slug: spec.slug,
134
- type: spec.type,
135
- })
136
- }
136
+ function resolveBaseBranch(config, git) {
137
+ const explicit = config && typeof config.baseBranch === 'string' && config.baseBranch.trim()
138
+ if (explicit) return explicit
139
+
140
+ const originHead = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
141
+ if (originHead) return originHead.replace(/^origin\//, '')
142
+
143
+ for (const name of ['main', 'master']) {
144
+ if (git(['show-ref', '--verify', '--quiet', `refs/heads/${name}`]) !== null) return name
137
145
  }
138
- return `${spec.type}/${spec.slug}`
146
+ return 'main'
139
147
  }
140
148
 
141
149
  /**
@@ -154,7 +162,7 @@ function resolveSpec(specArg, dir, config) {
154
162
 
155
163
  const stack = readStackField(found.path, config)
156
164
  const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack }
157
- const branch = branchFor(spec, dir, config)
165
+ const branch = branchFor(spec, config)
158
166
 
159
167
  const worktreeRoot = expandTokens(config.worktree.root, tokens)
160
168
  const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
@@ -175,6 +183,7 @@ function resolveSpec(specArg, dir, config) {
175
183
 
176
184
  module.exports = {
177
185
  resolveSpec,
186
+ resolveBaseBranch,
178
187
  branchFor,
179
188
  splitPrefix,
180
189
  repoInfo,
@@ -18,10 +18,10 @@
18
18
  const { expandTokens } = require('./resolve.js')
19
19
 
20
20
  /**
21
- * @param {object} spec resolved spec: { slug, worktreePath, projectName, ... }
21
+ * @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
22
22
  * @param {object} config normalised env config.
23
23
  * @param {object} flags { keepVolumes, force }
24
- * @param {object} ctx { worktreeState: { dirty, unpushed }, timestamp }
24
+ * @param {object} ctx { worktreeState: { dirty, unpushed, merged }, timestamp }
25
25
  * @returns {object} { blocked, reason, commands, backupCommand, backupPath,
26
26
  * volumesDropped }
27
27
  */
@@ -35,8 +35,16 @@ function planDown(spec, config, flags, ctx) {
35
35
  if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
36
36
  return blocked('worktree has uncommitted changes')
37
37
  }
38
- if (config.guards.refuseTeardownIfUnpushed && worktreeState.unpushed) {
39
- return blocked('worktree has unpushed commits')
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')
40
48
  }
41
49
  }
42
50
 
@@ -77,6 +85,13 @@ function planDown(spec, config, flags, ctx) {
77
85
  : `git worktree remove ${spec.worktreePath}`,
78
86
  )
79
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
+
80
95
  return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
81
96
  }
82
97
 
@@ -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
+ }