infra-kit 0.1.95 → 0.1.98

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 (34) hide show
  1. package/.eslintcache +1 -1
  2. package/.turbo/turbo-eslint-check.log +1 -1
  3. package/.turbo/turbo-prettier-check.log +1 -4
  4. package/.turbo/turbo-test.log +172 -63
  5. package/.turbo/turbo-ts-check.log +5 -6
  6. package/dist/cli.js +57 -36
  7. package/dist/cli.js.map +4 -4
  8. package/dist/mcp.js +24 -22
  9. package/dist/mcp.js.map +4 -4
  10. package/package.json +2 -2
  11. package/src/commands/config/config.ts +125 -0
  12. package/src/commands/config/index.ts +1 -0
  13. package/src/commands/doctor/doctor.ts +27 -18
  14. package/src/commands/init/init.ts +54 -1
  15. package/src/commands/release-create/release-create.ts +123 -72
  16. package/src/commands/release-create-batch/release-create-batch.ts +45 -21
  17. package/src/commands/worktrees-open/index.ts +1 -0
  18. package/src/commands/worktrees-open/worktrees-open.ts +197 -0
  19. package/src/commands/worktrees-remove/worktrees-remove.ts +4 -2
  20. package/src/entry/cli.ts +35 -6
  21. package/src/integrations/cmux/index.ts +1 -0
  22. package/src/integrations/cmux/list-workspace-titles.ts +42 -0
  23. package/src/integrations/cursor/index.ts +1 -0
  24. package/src/integrations/cursor/reconcile-workspace-folders.ts +90 -0
  25. package/src/lib/__tests__/infra-kit-config.test.ts +3 -1
  26. package/src/lib/git-utils/git-utils.ts +27 -8
  27. package/src/lib/infra-kit-config/index.ts +2 -2
  28. package/src/lib/infra-kit-config/infra-kit-config.ts +183 -37
  29. package/src/lib/version-utils/__tests__/next-version.test.ts +112 -0
  30. package/src/lib/version-utils/index.ts +11 -0
  31. package/src/lib/version-utils/load-existing-versions.ts +67 -0
  32. package/src/lib/version-utils/next-version.ts +148 -0
  33. package/src/mcp/tools/index.ts +2 -0
  34. package/tsconfig.tsbuildinfo +1 -1
@@ -1,12 +1,16 @@
1
1
  import fs from 'node:fs/promises'
2
+ import os from 'node:os'
2
3
  import path from 'node:path'
3
4
  import yaml from 'yaml'
4
5
  import { z } from 'zod/v4'
5
6
 
6
- import { getProjectRoot } from 'src/lib/git-utils'
7
+ import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
7
8
 
8
9
  const INFRA_KIT_CONFIG_FILE = 'infra-kit.yml'
9
- const INFRA_KIT_LOCAL_CONFIG_FILE = 'infra-kit.local.yml'
10
+
11
+ const USER_CONFIG_DIR_NAME = '.infra-kit'
12
+ const USER_GLOBAL_CONFIG_FILE = 'config.yml'
13
+ const USER_PROJECTS_DIR = 'projects'
10
14
 
11
15
  // envManagement
12
16
  const dopplerEnvManagementSchema = z.object({
@@ -59,81 +63,148 @@ const infraKitConfigSchema = z.object({
59
63
  taskManager: taskManagerSchema.optional(),
60
64
  })
61
65
 
62
- const infraKitLocalConfigSchema = infraKitConfigSchema.partial()
66
+ const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
63
67
 
64
68
  export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
65
69
 
70
+ export interface InfraKitConfigPaths {
71
+ /** Committed project config (required). */
72
+ main: string
73
+ /** User-scope global overrides applied to every project. */
74
+ userGlobal: string
75
+ /** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.yml`. */
76
+ userProject: string
77
+ /** Repo basename (`path.basename(projectRoot)`) used to namespace the user-project file. */
78
+ projectName: string
79
+ }
80
+
66
81
  interface CacheEntry {
67
- mainMtimeMs: number
68
- localMtimeMs: number | null
82
+ mtimes: Record<keyof Omit<InfraKitConfigPaths, 'projectName'>, number | null>
69
83
  value: InfraKitConfig
70
84
  }
71
85
 
72
86
  let cached: CacheEntry | null = null
73
87
 
74
88
  /**
75
- * Read and validate `infra-kit.yml`, with optional `infra-kit.local.yml` overrides
76
- * shallow-merged on top (per-developer, gitignored). Top-level keys (entire
77
- * capability sections like `ide`, `envManagement`) replace wholesale. Results are
78
- * cached per file mtimes so the long-running MCP server picks up edits without a
79
- * restart.
89
+ * Resolve every file path that participates in the config merge chain. Always
90
+ * returns paths even for files that don't yet exist, so callers can use them
91
+ * for "where would my override go?" prompts.
92
+ *
93
+ * @example
94
+ * const paths = await getInfraKitConfigPaths()
95
+ * // {
96
+ * // main: '/Users/arthur/projects/api/infra-kit.yml',
97
+ * // userGlobal: '/Users/arthur/.infra-kit/config.yml',
98
+ * // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.yml',
99
+ * // projectName: 'api',
100
+ * // }
80
101
  */
81
- export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
102
+ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> => {
82
103
  const projectRoot = await getProjectRoot()
83
- const mainPath = path.join(projectRoot, INFRA_KIT_CONFIG_FILE)
84
- const localPath = path.join(projectRoot, INFRA_KIT_LOCAL_CONFIG_FILE)
104
+ const projectName = await getRepoName()
105
+ const userConfigDir = path.join(os.homedir(), USER_CONFIG_DIR_NAME)
106
+
107
+ return {
108
+ main: path.join(projectRoot, INFRA_KIT_CONFIG_FILE),
109
+ userGlobal: path.join(userConfigDir, USER_GLOBAL_CONFIG_FILE),
110
+ userProject: path.join(userConfigDir, USER_PROJECTS_DIR, projectName, INFRA_KIT_CONFIG_FILE),
111
+ projectName,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Read and validate `infra-kit.yml`, with optional override layers shallow-merged
117
+ * on top in this order (later wins):
118
+ * 1. project `infra-kit.yml` — committed source of truth
119
+ * 2. `~/.infra-kit/config.yml` — user-global defaults
120
+ * 3. `~/.infra-kit/projects/<repo-name>/infra-kit.yml` — user-scope per-project overrides
121
+ *
122
+ * Top-level keys (entire capability sections like `ide`, `envManagement`)
123
+ * replace wholesale. Results are cached per file mtimes so the long-running
124
+ * MCP server picks up edits without a restart.
125
+ *
126
+ * @example
127
+ * // infra-kit.yml: { environments: ['dev'], envManagement: { provider: 'doppler', config: { name: 'p' } } }
128
+ * // ~/.infra-kit/config.yml: { ide: { provider: 'cursor', config: { mode: 'windows' } } }
129
+ * const cfg = await getInfraKitConfig()
130
+ * // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { mode: 'windows' } } }
131
+ */
132
+ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
133
+ const paths = await getInfraKitConfigPaths()
85
134
 
86
135
  let mainStat: Awaited<ReturnType<typeof fs.stat>>
87
136
 
88
137
  try {
89
- mainStat = await fs.stat(mainPath)
138
+ mainStat = await fs.stat(paths.main)
90
139
  } catch {
91
140
  cached = null
92
- throw new Error(`infra-kit.yml not found at ${mainPath}`)
141
+ throw new Error(`infra-kit.yml not found at ${paths.main}`)
93
142
  }
94
143
 
95
- const localStat = await statIfExists(localPath)
96
- const mainMtimeMs = Number(mainStat.mtimeMs)
97
- const localMtimeMs = localStat ? Number(localStat.mtimeMs) : null
144
+ const [userGlobalStat, userProjectStat] = await Promise.all([
145
+ statIfExists(paths.userGlobal),
146
+ statIfExists(paths.userProject),
147
+ ])
98
148
 
99
- if (cached && cached.mainMtimeMs === mainMtimeMs && cached.localMtimeMs === localMtimeMs) {
100
- return cached.value
149
+ const mtimes = {
150
+ main: Number(mainStat.mtimeMs),
151
+ userGlobal: userGlobalStat ? Number(userGlobalStat.mtimeMs) : null,
152
+ userProject: userProjectStat ? Number(userProjectStat.mtimeMs) : null,
101
153
  }
102
154
 
103
- const mainRaw = await fs.readFile(mainPath, 'utf-8')
104
- const mainParsed = yaml.parse(mainRaw)
155
+ if (cached && shallowEqual(cached.mtimes, mtimes)) {
156
+ return cached.value
157
+ }
105
158
 
106
- let merged: unknown = mainParsed
159
+ const layers: ConfigLayer[] = [
160
+ { label: 'infra-kit.yml', path: paths.main, required: true },
161
+ { label: '~/.infra-kit/config.yml', path: paths.userGlobal, required: false },
162
+ {
163
+ label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.yml`,
164
+ path: paths.userProject,
165
+ required: false,
166
+ },
167
+ ]
107
168
 
108
- if (localStat) {
109
- const localRaw = await fs.readFile(localPath, 'utf-8')
110
- const localParsedRaw = yaml.parse(localRaw) ?? {}
169
+ let merged: Record<string, unknown> = {}
111
170
 
112
- const localResult = infraKitLocalConfigSchema.safeParse(localParsedRaw)
171
+ for (const layer of layers) {
172
+ const data = await loadLayer(layer)
113
173
 
114
- if (!localResult.success) {
115
- throw new Error(`Invalid infra-kit.local.yml at ${localPath}: ${z.prettifyError(localResult.error)}`)
116
- }
174
+ if (data === null) continue
117
175
 
118
- merged = { ...(mainParsed as object), ...localResult.data }
176
+ merged = { ...merged, ...data }
119
177
  }
120
178
 
121
- const result = infraKitConfigSchema.safeParse(merged)
179
+ const finalResult = infraKitConfigSchema.safeParse(merged)
122
180
 
123
- if (!result.success) {
124
- throw new Error(`Invalid infra-kit.yml at ${mainPath}: ${z.prettifyError(result.error)}`)
181
+ if (!finalResult.success) {
182
+ throw new Error(`Invalid merged infra-kit config: ${z.prettifyError(finalResult.error)}`)
125
183
  }
126
184
 
127
- cached = { mainMtimeMs, localMtimeMs, value: result.data }
185
+ cached = { mtimes, value: finalResult.data }
128
186
 
129
- return result.data
187
+ return finalResult.data
130
188
  }
131
189
 
132
- /** For tests — drops the in-memory cache. */
190
+ /**
191
+ * For tests — drops the in-memory cache so the next read hits disk.
192
+ *
193
+ * @example
194
+ * resetInfraKitConfigCache()
195
+ * await getInfraKitConfig() // re-reads files even if mtimes look unchanged
196
+ */
133
197
  export const resetInfraKitConfigCache = (): void => {
134
198
  cached = null
135
199
  }
136
200
 
201
+ /**
202
+ * `fs.stat` that returns `null` instead of throwing on ENOENT. Used so the
203
+ * resolver can probe optional files in the merge chain without try/catch noise.
204
+ *
205
+ * @example
206
+ * const stat = await statIfExists('/does/not/exist') // => null
207
+ */
137
208
  const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof fs.stat>> | null> => {
138
209
  try {
139
210
  return await fs.stat(filePath)
@@ -141,3 +212,78 @@ const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof
141
212
  return null
142
213
  }
143
214
  }
215
+
216
+ /**
217
+ * `fs.readFile` that returns `null` instead of throwing on ENOENT.
218
+ *
219
+ * @example
220
+ * const raw = await readIfExists('/missing.yml') // => null
221
+ * const raw = await readIfExists('/exists.yml') // => 'environments: [dev]\n'
222
+ */
223
+ const readIfExists = async (filePath: string): Promise<string | null> => {
224
+ try {
225
+ return await fs.readFile(filePath, 'utf-8')
226
+ } catch {
227
+ return null
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Reference-equality comparison of every key in two flat records. Used to
233
+ * cheaply detect whether the cached mtime fingerprint still matches.
234
+ *
235
+ * @example
236
+ * shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }) // => true
237
+ * shallowEqual({ a: 1 }, { a: 1, b: 2 }) // => false
238
+ * shallowEqual({ a: 1 }, { a: 2 }) // => false
239
+ */
240
+ const shallowEqual = <T extends Record<string, unknown>>(a: T, b: T): boolean => {
241
+ const keys = Object.keys(a)
242
+
243
+ if (keys.length !== Object.keys(b).length) return false
244
+
245
+ return keys.every((k) => {
246
+ return a[k] === b[k]
247
+ })
248
+ }
249
+
250
+ interface ConfigLayer {
251
+ label: string
252
+ path: string
253
+ required: boolean
254
+ }
255
+
256
+ /**
257
+ * Read a single layer of the merge chain: parse the YAML if the file exists
258
+ * and validate it against the override schema. Returns `null` if an optional
259
+ * layer is missing; throws if the layer is required or invalid.
260
+ *
261
+ * @example
262
+ * await loadLayer({ label: '~/.infra-kit/config.yml', path: '/missing.yml', required: false })
263
+ * // => null
264
+ *
265
+ * @example
266
+ * // /home/me/.infra-kit/config.yml: 'ide:\n provider: cursor\n config: { mode: windows }'
267
+ * await loadLayer({ label: '~/.infra-kit/config.yml', path: '/home/me/.infra-kit/config.yml', required: false })
268
+ * // => { ide: { provider: 'cursor', config: { mode: 'windows' } } }
269
+ */
270
+ const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
271
+ const raw = await readIfExists(layer.path)
272
+
273
+ if (raw === null) {
274
+ if (layer.required) {
275
+ throw new Error(`${layer.label} not found at ${layer.path}`)
276
+ }
277
+
278
+ return null
279
+ }
280
+
281
+ const parsedRaw = yaml.parse(raw) ?? {}
282
+ const result = infraKitOverrideConfigSchema.safeParse(parsedRaw)
283
+
284
+ if (!result.success) {
285
+ throw new Error(`Invalid ${layer.label} at ${layer.path}: ${z.prettifyError(result.error)}`)
286
+ }
287
+
288
+ return result.data as Record<string, unknown>
289
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ NoPriorVersionsError,
5
+ collectKnownVersions,
6
+ computeNextVersion,
7
+ resolveVersionTokens,
8
+ splitVersionInput,
9
+ } from '../next-version'
10
+
11
+ describe('collectKnownVersions', () => {
12
+ it('parses remote branch refs and Jira version names and dedupes', () => {
13
+ const known = collectKnownVersions({
14
+ remoteBranches: ['release/v1.62.0', 'refs/heads/release/v1.63.0', 'release/v1.62.0'],
15
+ jiraVersions: ['v1.63.0', 'v1.64.5'],
16
+ })
17
+
18
+ expect(known).toEqual([
19
+ [1, 62, 0],
20
+ [1, 63, 0],
21
+ [1, 64, 5],
22
+ ])
23
+ })
24
+
25
+ it('ignores non-semver inputs', () => {
26
+ const known = collectKnownVersions({
27
+ remoteBranches: ['release/v1.62.0', 'release/v-bogus', 'main'],
28
+ jiraVersions: ['v1.63.0', 'random-name'],
29
+ })
30
+
31
+ expect(known).toEqual([
32
+ [1, 62, 0],
33
+ [1, 63, 0],
34
+ ])
35
+ })
36
+
37
+ it('returns [] when sources are empty or undefined', () => {
38
+ expect(collectKnownVersions({})).toEqual([])
39
+ expect(collectKnownVersions({ remoteBranches: [], jiraVersions: [] })).toEqual([])
40
+ })
41
+ })
42
+
43
+ describe('computeNextVersion', () => {
44
+ it('regular bumps minor and resets patch', () => {
45
+ const known = collectKnownVersions({ remoteBranches: ['release/v1.63.5', 'release/v1.62.0'] })
46
+
47
+ expect(computeNextVersion(known, 'regular')).toBe('1.64.0')
48
+ })
49
+
50
+ it('hotfix bumps patch on the highest minor (any patch)', () => {
51
+ const known = collectKnownVersions({
52
+ remoteBranches: ['release/v1.63.5', 'release/v1.63.0', 'release/v1.62.0'],
53
+ })
54
+
55
+ expect(computeNextVersion(known, 'hotfix')).toBe('1.63.6')
56
+ })
57
+
58
+ it('hotfix uses the highest minor even when patch chain has gaps', () => {
59
+ const known = collectKnownVersions({
60
+ remoteBranches: ['release/v1.55.10', 'release/v1.55.20', 'release/v1.55.30'],
61
+ })
62
+
63
+ expect(computeNextVersion(known, 'hotfix')).toBe('1.55.31')
64
+ })
65
+
66
+ it('throws NoPriorVersionsError when there are no known versions', () => {
67
+ expect(() => {
68
+ return computeNextVersion([], 'regular')
69
+ }).toThrow(NoPriorVersionsError)
70
+ })
71
+ })
72
+
73
+ describe('resolveVersionTokens', () => {
74
+ const known = collectKnownVersions({ remoteBranches: ['release/v1.63.0'] })
75
+
76
+ it('resolves "next,next" sequentially for regular', () => {
77
+ expect(resolveVersionTokens(['next', 'next'], 'regular', known)).toEqual(['1.64.0', '1.65.0'])
78
+ })
79
+
80
+ it('mixes literals and next, advancing running max', () => {
81
+ expect(resolveVersionTokens(['next', '1.70.0', 'next'], 'regular', known)).toEqual(['1.64.0', '1.70.0', '1.71.0'])
82
+ })
83
+
84
+ it('accepts NEXT and " next " (case + whitespace insensitive)', () => {
85
+ expect(resolveVersionTokens(['NEXT', ' next '], 'regular', known)).toEqual(['1.64.0', '1.65.0'])
86
+ })
87
+
88
+ it('strips leading v on explicit versions', () => {
89
+ expect(resolveVersionTokens(['v1.70.0'], 'regular', known)).toEqual(['1.70.0'])
90
+ })
91
+
92
+ it('throws on invalid token', () => {
93
+ expect(() => {
94
+ return resolveVersionTokens(['nope'], 'regular', known)
95
+ }).toThrow(/Invalid version/)
96
+ })
97
+
98
+ it('hotfix sequence advances patch each step', () => {
99
+ expect(resolveVersionTokens(['next', 'next'], 'hotfix', known)).toEqual(['1.63.1', '1.63.2'])
100
+ })
101
+ })
102
+
103
+ describe('splitVersionInput', () => {
104
+ it('splits comma-separated input and trims', () => {
105
+ expect(splitVersionInput(' 1.2.3 , next, ,1.2.4 ')).toEqual(['1.2.3', 'next', '1.2.4'])
106
+ })
107
+
108
+ it('returns empty array for empty input', () => {
109
+ expect(splitVersionInput('')).toEqual([])
110
+ expect(splitVersionInput(' ')).toEqual([])
111
+ })
112
+ })
@@ -1 +1,12 @@
1
+ export { loadExistingVersions } from './load-existing-versions'
2
+ export {
3
+ collectKnownVersions,
4
+ computeNextVersion,
5
+ type ExistingVersionsSources,
6
+ NEXT_TOKEN,
7
+ NoPriorVersionsError,
8
+ resolveVersionTokens,
9
+ type SemVer,
10
+ splitVersionInput,
11
+ } from './next-version'
1
12
  export { parseVersion, sortVersions } from './version-utils'
@@ -0,0 +1,67 @@
1
+ import { $ } from 'zx'
2
+
3
+ import { getProjectVersions, loadJiraConfigOptional } from 'src/integrations/jira'
4
+ import { logger } from 'src/lib/logger'
5
+
6
+ import { collectKnownVersions } from './next-version'
7
+ import type { SemVer } from './next-version'
8
+
9
+ const parseRemoteRefs = async (): Promise<string[]> => {
10
+ const previousQuiet = $.quiet
11
+
12
+ try {
13
+ $.quiet = true
14
+ const result = await $`git ls-remote --heads origin 'release/v*'`
15
+ const lines = result.stdout.split('\n')
16
+
17
+ return lines
18
+ .map((line) => {
19
+ const tab = line.indexOf('\t')
20
+
21
+ if (tab === -1) return ''
22
+
23
+ return line.slice(tab + 1).replace(/^refs\/heads\//, '')
24
+ })
25
+ .filter(Boolean)
26
+ } finally {
27
+ $.quiet = previousQuiet
28
+ }
29
+ }
30
+
31
+ const fetchJiraVersionNames = async (): Promise<string[]> => {
32
+ const config = await loadJiraConfigOptional()
33
+
34
+ if (!config) return []
35
+
36
+ const versions = await getProjectVersions(config)
37
+
38
+ return versions.map((v) => {
39
+ return v.name
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Load known release versions from the union of:
45
+ * - remote release branches (`release/v*` on origin)
46
+ * - Jira fix versions (when configured)
47
+ *
48
+ * Each source is queried in parallel; if either fails, we log a warning
49
+ * and continue with the other so a transient outage doesn't block release
50
+ * creation.
51
+ */
52
+ export const loadExistingVersions = async (): Promise<SemVer[]> => {
53
+ const [branchesResult, jiraResult] = await Promise.allSettled([parseRemoteRefs(), fetchJiraVersionNames()])
54
+
55
+ if (branchesResult.status === 'rejected') {
56
+ logger.warn({ error: branchesResult.reason }, 'Failed to list remote release branches; continuing without them')
57
+ }
58
+
59
+ if (jiraResult.status === 'rejected') {
60
+ logger.warn({ error: jiraResult.reason }, 'Failed to fetch Jira versions; continuing without them')
61
+ }
62
+
63
+ return collectKnownVersions({
64
+ remoteBranches: branchesResult.status === 'fulfilled' ? branchesResult.value : [],
65
+ jiraVersions: jiraResult.status === 'fulfilled' ? jiraResult.value : [],
66
+ })
67
+ }
@@ -0,0 +1,148 @@
1
+ import type { ReleaseType } from 'src/lib/release-utils'
2
+
3
+ import { parseVersion, sortVersions } from './version-utils'
4
+
5
+ export const NEXT_TOKEN = 'next'
6
+
7
+ export type SemVer = readonly [number, number, number]
8
+
9
+ export interface ExistingVersionsSources {
10
+ remoteBranches?: string[]
11
+ jiraVersions?: string[]
12
+ }
13
+
14
+ const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)$/
15
+
16
+ const stripBranchPrefix = (raw: string): string => {
17
+ return raw.replace(/^.*release\//, '')
18
+ }
19
+
20
+ const tryParse = (raw: string): SemVer | null => {
21
+ const cleaned = stripBranchPrefix(raw.trim())
22
+ const match = VERSION_RE.exec(cleaned)
23
+
24
+ if (!match) return null
25
+
26
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
27
+ }
28
+
29
+ const semverKey = (v: SemVer): string => {
30
+ return `${v[0]}.${v[1]}.${v[2]}`
31
+ }
32
+
33
+ export const collectKnownVersions = (sources: ExistingVersionsSources): SemVer[] => {
34
+ const all = [...(sources.remoteBranches ?? []), ...(sources.jiraVersions ?? [])]
35
+ const parsed: SemVer[] = []
36
+ const seen = new Set<string>()
37
+
38
+ for (const raw of all) {
39
+ const v = tryParse(raw)
40
+
41
+ if (!v) continue
42
+
43
+ const key = semverKey(v)
44
+
45
+ if (seen.has(key)) continue
46
+
47
+ seen.add(key)
48
+ parsed.push(v)
49
+ }
50
+
51
+ return sortVersions(
52
+ parsed.map((v) => {
53
+ return semverKey(v)
54
+ }),
55
+ ).map((s) => {
56
+ return parseVersion(`v${s}`)
57
+ })
58
+ }
59
+
60
+ export class NoPriorVersionsError extends Error {
61
+ constructor() {
62
+ super('No prior release versions found from git or Jira. Specify the version explicitly.')
63
+ this.name = 'NoPriorVersionsError'
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Compute the next semantic version based on release type.
69
+ * - regular: bump minor, reset patch to 0
70
+ * - hotfix: bump patch on the highest minor (any patch)
71
+ *
72
+ * Returns the version without the leading "v" (e.g. "1.64.0").
73
+ */
74
+ export const computeNextVersion = (known: SemVer[], type: ReleaseType): string => {
75
+ if (known.length === 0) throw new NoPriorVersionsError()
76
+
77
+ const max = known[known.length - 1] as SemVer
78
+
79
+ if (type === 'hotfix') {
80
+ const [major, minor] = max
81
+
82
+ const highestPatchOnMinor = known.reduce((acc, v) => {
83
+ if (v[0] === major && v[1] === minor) return Math.max(acc, v[2])
84
+
85
+ return acc
86
+ }, 0)
87
+
88
+ return `${major}.${minor}.${highestPatchOnMinor + 1}`
89
+ }
90
+
91
+ const [major, minor] = max
92
+
93
+ return `${major}.${minor + 1}.0`
94
+ }
95
+
96
+ const isNextToken = (token: string): boolean => {
97
+ return token.trim().toLowerCase() === NEXT_TOKEN
98
+ }
99
+
100
+ /**
101
+ * Resolve a list of input tokens (mix of "next" and explicit semver strings)
102
+ * into concrete version strings. Each "next" advances based on the running
103
+ * max so "next,next" produces sequential versions.
104
+ */
105
+ export const resolveVersionTokens = (tokens: string[], type: ReleaseType, known: SemVer[]): string[] => {
106
+ const running: SemVer[] = [...known]
107
+ const resolved: string[] = []
108
+
109
+ for (const token of tokens) {
110
+ const trimmed = token.trim()
111
+
112
+ if (trimmed === '') continue
113
+
114
+ if (isNextToken(trimmed)) {
115
+ const next = computeNextVersion(running, type)
116
+
117
+ resolved.push(next)
118
+ running.push(parseVersion(`v${next}`))
119
+ continue
120
+ }
121
+
122
+ const parsed = tryParse(trimmed)
123
+
124
+ if (!parsed) {
125
+ throw new Error(`Invalid version "${trimmed}". Expected semver like "1.2.5" or the token "next".`)
126
+ }
127
+
128
+ const explicit = `${parsed[0]}.${parsed[1]}.${parsed[2]}`
129
+
130
+ resolved.push(explicit)
131
+ running.push(parsed)
132
+ }
133
+
134
+ return resolved
135
+ }
136
+
137
+ /**
138
+ * Split a raw user input into tokens, trimming and removing empties.
139
+ * Accepts both whitespace-separated and comma-separated lists.
140
+ */
141
+ export const splitVersionInput = (input: string): string[] => {
142
+ return input
143
+ .split(',')
144
+ .map((t) => {
145
+ return t.trim()
146
+ })
147
+ .filter(Boolean)
148
+ }
@@ -14,6 +14,7 @@ import { releaseCreateBatchMcpTool } from 'src/commands/release-create-batch'
14
14
  import { versionMcpTool } from 'src/commands/version'
15
15
  import { worktreesAddMcpTool } from 'src/commands/worktrees-add'
16
16
  import { worktreesListMcpTool } from 'src/commands/worktrees-list'
17
+ import { worktreesOpenMcpTool } from 'src/commands/worktrees-open'
17
18
  import { worktreesRemoveMcpTool } from 'src/commands/worktrees-remove'
18
19
  import { worktreesSyncMcpTool } from 'src/commands/worktrees-sync'
19
20
  import { createToolHandler } from 'src/lib/tool-handler'
@@ -33,6 +34,7 @@ const tools = [
33
34
  versionMcpTool,
34
35
  worktreesAddMcpTool,
35
36
  worktreesListMcpTool,
37
+ worktreesOpenMcpTool,
36
38
  worktreesRemoveMcpTool,
37
39
  worktreesSyncMcpTool,
38
40
  ]