infra-kit 0.1.95 → 0.1.97

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infra-kit",
3
3
  "type": "module",
4
- "version": "0.1.95",
4
+ "version": "0.1.97",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/cli.js",
7
7
  "module": "dist/cli.js",
@@ -37,7 +37,7 @@
37
37
  "commander": "^14.0.3",
38
38
  "pino": "^10.3.1",
39
39
  "pino-pretty": "^13.1.3",
40
- "yaml": "^2.8.3",
40
+ "yaml": "^2.8.4",
41
41
  "zod": "^3.25.76",
42
42
  "zx": "^8.8.5"
43
43
  },
@@ -0,0 +1 @@
1
+ export { worktreesOpen, worktreesOpenMcpTool } from './worktrees-open'
@@ -0,0 +1,197 @@
1
+ import { z } from 'zod/v4'
2
+ import { $ } from 'zx'
3
+
4
+ import { buildCmuxWorkspaceTitle, listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
5
+ import { reconcileCursorWorkspaceFolders, resolveCursorWorkspacePath } from 'src/integrations/cursor'
6
+ import { commandEcho } from 'src/lib/command-echo'
7
+ import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
8
+ import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
9
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
10
+ import { logger } from 'src/lib/logger'
11
+ import type { ToolsExecutionResult } from 'src/types'
12
+
13
+ interface WorktreesOpenResult {
14
+ openedCmux: string[]
15
+ skippedCmux: string[]
16
+ cursorFoldersAdded: number
17
+ cursorFoldersRemoved: number
18
+ }
19
+
20
+ /**
21
+ * Cold-start restore command: reconciles `Main.code-workspace` against the set
22
+ * of release worktrees on disk, opens Cursor against it, and ensures one cmux
23
+ * workspace exists per worktree. Idempotent and additive — never removes
24
+ * worktrees, never recreates running cmux workspaces.
25
+ */
26
+ export const worktreesOpen = async (): Promise<ToolsExecutionResult> => {
27
+ commandEcho.start('worktrees-open')
28
+
29
+ try {
30
+ const projectRoot = await getProjectRoot()
31
+ const worktreeDir = `${projectRoot}${WORKTREES_DIR_SUFFIX}`
32
+ const currentBranches = await getCurrentWorktrees('release')
33
+
34
+ const cursorOutcome = await openCursor({ projectRoot, worktreeDir, currentBranches })
35
+ const cmuxOutcome = await openCmux({ worktreeDir, currentBranches })
36
+
37
+ const result: WorktreesOpenResult = {
38
+ openedCmux: cmuxOutcome.opened,
39
+ skippedCmux: cmuxOutcome.skipped,
40
+ cursorFoldersAdded: cursorOutcome.added,
41
+ cursorFoldersRemoved: cursorOutcome.removed,
42
+ }
43
+
44
+ logResults(result, { cursorRan: cursorOutcome.ran, cmuxRan: cmuxOutcome.ran })
45
+
46
+ commandEcho.print()
47
+
48
+ return {
49
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
50
+ structuredContent: { ...result },
51
+ }
52
+ } catch (error) {
53
+ logger.error({ error }, '❌ Error opening worktrees')
54
+ throw error
55
+ }
56
+ }
57
+
58
+ interface OpenCursorArgs {
59
+ projectRoot: string
60
+ worktreeDir: string
61
+ currentBranches: string[]
62
+ }
63
+
64
+ interface OpenCursorOutcome {
65
+ ran: boolean
66
+ added: number
67
+ removed: number
68
+ }
69
+
70
+ const openCursor = async (args: OpenCursorArgs): Promise<OpenCursorOutcome> => {
71
+ const { projectRoot, worktreeDir, currentBranches } = args
72
+
73
+ const config = await getInfraKitConfig()
74
+ const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
75
+
76
+ if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
77
+ logger.warn('⚠️ Skipping Cursor: ide.provider must be "cursor", mode "workspace", and workspaceConfigPath set.')
78
+
79
+ return { ran: false, added: 0, removed: 0 }
80
+ }
81
+
82
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
83
+
84
+ try {
85
+ const { added, removed } = await reconcileCursorWorkspaceFolders({
86
+ workspacePath,
87
+ worktreeDir,
88
+ currentBranches,
89
+ })
90
+
91
+ await $`cursor ${workspacePath}`
92
+
93
+ return { ran: true, added: added.length, removed: removed.length }
94
+ } catch (error) {
95
+ logger.warn({ error }, `⚠️ Failed to reconcile/open Cursor workspace at ${workspacePath}`)
96
+
97
+ return { ran: false, added: 0, removed: 0 }
98
+ }
99
+ }
100
+
101
+ interface OpenCmuxArgs {
102
+ worktreeDir: string
103
+ currentBranches: string[]
104
+ }
105
+
106
+ interface OpenCmuxOutcome {
107
+ ran: boolean
108
+ opened: string[]
109
+ skipped: string[]
110
+ }
111
+
112
+ const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
113
+ const { worktreeDir, currentBranches } = args
114
+
115
+ if (currentBranches.length === 0) {
116
+ return { ran: true, opened: [], skipped: [] }
117
+ }
118
+
119
+ const repoName = await getRepoName()
120
+ const existingTitles = await listCmuxWorkspaceTitles()
121
+
122
+ const opened: string[] = []
123
+ const skipped: string[] = []
124
+
125
+ for (const branch of currentBranches) {
126
+ const title = buildCmuxWorkspaceTitle({ repoName, branch })
127
+
128
+ if (existingTitles.has(title)) {
129
+ skipped.push(title)
130
+ continue
131
+ }
132
+
133
+ try {
134
+ await openCmuxWorkspaceWithLayout({ cwd: `${worktreeDir}/${branch}`, title })
135
+ opened.push(title)
136
+ } catch (error) {
137
+ logger.warn({ error, title }, `⚠️ Failed to open cmux workspace for ${branch}`)
138
+ }
139
+ }
140
+
141
+ return { ran: true, opened, skipped }
142
+ }
143
+
144
+ interface LogResultsContext {
145
+ cursorRan: boolean
146
+ cmuxRan: boolean
147
+ }
148
+
149
+ const logResults = (result: WorktreesOpenResult, context: LogResultsContext): void => {
150
+ if (context.cursorRan) {
151
+ if (result.cursorFoldersAdded > 0) {
152
+ logger.info(`✅ Added ${result.cursorFoldersAdded} folder(s) to Cursor workspace`)
153
+ }
154
+
155
+ if (result.cursorFoldersRemoved > 0) {
156
+ logger.info(`🧹 Removed ${result.cursorFoldersRemoved} dangling folder(s) from Cursor workspace`)
157
+ }
158
+ }
159
+
160
+ if (result.openedCmux.length > 0) {
161
+ logger.info('✅ Opened cmux workspaces:')
162
+ for (const title of result.openedCmux) {
163
+ logger.info(title)
164
+ }
165
+ }
166
+
167
+ if (result.skippedCmux.length > 0) {
168
+ logger.info(`ℹ️ Skipped ${result.skippedCmux.length} cmux workspace(s) already open`)
169
+ }
170
+
171
+ if (
172
+ !context.cursorRan &&
173
+ result.openedCmux.length === 0 &&
174
+ result.skippedCmux.length === 0 &&
175
+ result.cursorFoldersAdded === 0 &&
176
+ result.cursorFoldersRemoved === 0
177
+ ) {
178
+ logger.info('ℹ️ Nothing to open')
179
+ }
180
+ }
181
+
182
+ // MCP Tool Registration
183
+ export const worktreesOpenMcpTool = {
184
+ name: 'worktrees-open',
185
+ description:
186
+ 'Open Cursor against the configured workspace file and ensure a cmux workspace exists for each existing release worktree. Idempotent and additive — never removes worktrees, never recreates running cmux workspaces. Use after a cold start (Cursor + cmux closed). For stale-worktree cleanup, use worktrees-sync.',
187
+ inputSchema: {},
188
+ outputSchema: {
189
+ openedCmux: z.array(z.string()).describe('Titles of cmux workspaces opened during this run'),
190
+ skippedCmux: z.array(z.string()).describe('Titles of cmux workspaces that were already open'),
191
+ cursorFoldersAdded: z.number().describe('Number of worktree folders added to the Cursor workspace file'),
192
+ cursorFoldersRemoved: z
193
+ .number()
194
+ .describe('Number of dangling worktree folders removed from the Cursor workspace file'),
195
+ },
196
+ handler: worktreesOpen,
197
+ }
@@ -116,6 +116,7 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
116
116
  branches: selectedReleaseBranches,
117
117
  worktreeDir,
118
118
  repoName,
119
+ allSelected,
119
120
  })
120
121
 
121
122
  await syncCursorWorkspaceOnRemove({ removedWorktrees, worktreeDir, projectRoot })
@@ -148,13 +149,14 @@ interface RemoveWorktreesArgs {
148
149
  branches: string[]
149
150
  worktreeDir: string
150
151
  repoName: string
152
+ allSelected: boolean
151
153
  }
152
154
 
153
155
  /**
154
156
  * Remove worktrees for the specified branches and whole folder
155
157
  */
156
158
  const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> => {
157
- const { branches, worktreeDir, repoName } = args
159
+ const { branches, worktreeDir, repoName, allSelected } = args
158
160
 
159
161
  const results = await Promise.allSettled(
160
162
  branches.map(async (branch) => {
@@ -182,7 +184,7 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
182
184
  }
183
185
  }
184
186
 
185
- if (removed.length === branches.length) {
187
+ if (allSelected && removed.length === branches.length) {
186
188
  await $`git worktree prune`
187
189
  await $`rm -rf ${worktreeDir}`
188
190
 
package/src/entry/cli.ts CHANGED
@@ -19,6 +19,7 @@ import { version } from 'src/commands/version'
19
19
  import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
20
20
  import type { CursorMode } from 'src/commands/worktrees-add'
21
21
  import { worktreesList } from 'src/commands/worktrees-list'
22
+ import { worktreesOpen } from 'src/commands/worktrees-open'
22
23
  import { worktreesRemove } from 'src/commands/worktrees-remove'
23
24
  import { worktreesSync } from 'src/commands/worktrees-sync'
24
25
  import { logger } from 'src/lib/logger'
@@ -191,6 +192,13 @@ program
191
192
  await worktreesRemove({ confirmedCommand: options.yes, all: options.all, versions: options.versions })
192
193
  })
193
194
 
195
+ program
196
+ .command('worktrees-open')
197
+ .description('Open Cursor + cmux for existing release worktrees (cold-start restore)')
198
+ .action(async () => {
199
+ await worktreesOpen()
200
+ })
201
+
194
202
  program
195
203
  .command('doctor')
196
204
  .description('Check installation and authentication status of gh and doppler CLIs')
@@ -251,7 +259,7 @@ if (process.argv.length <= 2) {
251
259
  'release-deploy-selected',
252
260
  'release-deliver',
253
261
  ]
254
- const worktreeCommands = ['worktrees-add', 'worktrees-list', 'worktrees-remove', 'worktrees-sync']
262
+ const worktreeCommands = ['worktrees-add', 'worktrees-list', 'worktrees-open', 'worktrees-remove', 'worktrees-sync']
255
263
  const envCommands = ['doctor', 'init', 'version', 'env-status', 'env-list', 'env-load', 'env-clear']
256
264
 
257
265
  const commandMap = new Map(
@@ -1,3 +1,4 @@
1
1
  export { closeCmuxWorkspaceByTitle } from './close-workspace-by-title'
2
+ export { listCmuxWorkspaceTitles } from './list-workspace-titles'
2
3
  export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
3
4
  export { buildCmuxWorkspaceTitle } from './workspace-title'
@@ -0,0 +1,42 @@
1
+ import { $ } from 'zx'
2
+
3
+ import { logger } from 'src/lib/logger'
4
+
5
+ /**
6
+ * Returns the set of titles for all currently-open cmux workspaces.
7
+ * Returns an empty set if cmux isn't running, the call fails, or the
8
+ * output can't be parsed — callers should treat "empty" as "unknown,
9
+ * proceed as if nothing is open".
10
+ *
11
+ * Each line of `cmux list-workspaces` looks like:
12
+ * " workspace:8 hulyo-monorepo v1.48.0"
13
+ * "* workspace:6 obsidian-workspace [selected]"
14
+ */
15
+ export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
16
+ try {
17
+ const output = (await $`cmux list-workspaces`.quiet()).stdout
18
+
19
+ const titles = new Set<string>()
20
+
21
+ for (const rawLine of output.split('\n')) {
22
+ // eslint-disable-next-line sonarjs/slow-regex, regexp/no-super-linear-backtracking
23
+ const match = rawLine.match(/^[* ]\s*workspace:\d+\s+(.+?)(?:\s+\[selected\])?\s*$/)
24
+
25
+ if (!match) {
26
+ continue
27
+ }
28
+
29
+ const title = match[1]?.trim()
30
+
31
+ if (title) {
32
+ titles.add(title)
33
+ }
34
+ }
35
+
36
+ return titles
37
+ } catch (error) {
38
+ logger.debug({ error }, 'cmux: skipped listing workspace titles')
39
+
40
+ return new Set()
41
+ }
42
+ }
@@ -1,3 +1,4 @@
1
1
  export { addFoldersToCursorWorkspace } from './add-folders-to-workspace'
2
+ export { reconcileCursorWorkspaceFolders } from './reconcile-workspace-folders'
2
3
  export { removeFoldersFromCursorWorkspace } from './remove-folders-from-workspace'
3
4
  export { resolveCursorWorkspacePath } from './resolve-workspace-path'
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { addFoldersToCursorWorkspace } from './add-folders-to-workspace'
5
+ import { removeFoldersFromCursorWorkspace } from './remove-folders-from-workspace'
6
+
7
+ interface ReconcileCursorWorkspaceFoldersArgs {
8
+ workspacePath: string
9
+ worktreeDir: string
10
+ currentBranches: string[]
11
+ }
12
+
13
+ interface ReconcileCursorWorkspaceFoldersResult {
14
+ added: string[]
15
+ removed: string[]
16
+ }
17
+
18
+ interface WorkspaceFolderEntry {
19
+ path: string
20
+ name?: string
21
+ }
22
+
23
+ interface WorkspaceFile {
24
+ folders?: WorkspaceFolderEntry[]
25
+ [key: string]: unknown
26
+ }
27
+
28
+ /**
29
+ * Reconciles the configured Cursor workspace's `folders` array against the
30
+ * actual set of release worktrees on disk:
31
+ * - Adds any worktree folders that aren't already listed.
32
+ * - Removes entries whose absolute path lives under `${worktreeDir}/release/`
33
+ * but no longer corresponds to a current branch (drift from manual
34
+ * `git worktree remove`, deleted branches, etc.).
35
+ *
36
+ * Non-worktree folder entries are left untouched.
37
+ */
38
+ export const reconcileCursorWorkspaceFolders = async (
39
+ args: ReconcileCursorWorkspaceFoldersArgs,
40
+ ): Promise<ReconcileCursorWorkspaceFoldersResult> => {
41
+ const { workspacePath, worktreeDir, currentBranches } = args
42
+
43
+ const workspaceDir = path.dirname(workspacePath)
44
+ const releaseRoot = path.resolve(`${worktreeDir}/release`)
45
+
46
+ const raw = await fs.readFile(workspacePath, 'utf-8')
47
+ const parsed = JSON.parse(raw) as WorkspaceFile
48
+
49
+ const existingFolders = parsed.folders ?? []
50
+
51
+ const desiredAbsolutePaths = new Set(
52
+ currentBranches.map((branch) => {
53
+ return path.resolve(`${worktreeDir}/${branch}`)
54
+ }),
55
+ )
56
+
57
+ const danglingFolderPaths: string[] = []
58
+
59
+ for (const entry of existingFolders) {
60
+ const entryAbsolutePath = path.resolve(workspaceDir, entry.path)
61
+
62
+ const isReleaseShaped = entryAbsolutePath === releaseRoot || entryAbsolutePath.startsWith(`${releaseRoot}/`)
63
+
64
+ if (isReleaseShaped && !desiredAbsolutePaths.has(entryAbsolutePath)) {
65
+ danglingFolderPaths.push(entryAbsolutePath)
66
+ }
67
+ }
68
+
69
+ let removed: string[] = []
70
+
71
+ if (danglingFolderPaths.length > 0) {
72
+ const result = await removeFoldersFromCursorWorkspace({
73
+ workspacePath,
74
+ folderPaths: danglingFolderPaths,
75
+ })
76
+
77
+ removed = result.removed
78
+ }
79
+
80
+ const desiredFolderPaths = currentBranches.map((branch) => {
81
+ return `${worktreeDir}/${branch}`
82
+ })
83
+
84
+ const { added } =
85
+ desiredFolderPaths.length > 0
86
+ ? await addFoldersToCursorWorkspace({ workspacePath, folderPaths: desiredFolderPaths })
87
+ : { added: [] as string[] }
88
+
89
+ return { added, removed }
90
+ }
@@ -21,6 +21,29 @@ export const getCurrentWorktrees = async (type: 'release' | 'feature'): Promise<
21
21
  })
22
22
  }
23
23
 
24
+ /**
25
+ * Extract the branch name from a `git worktree list` output line.
26
+ *
27
+ * `git worktree list` formats each line as:
28
+ * <path> <hash> [<branch>]
29
+ *
30
+ * Reads the branch from the trailing `[branch]` token so it works for the
31
+ * main checkout too (whose path does not encode the branch name).
32
+ */
33
+ const parseWorktreeBranch = (line: string): string | null => {
34
+ const trimmed = line.trimEnd()
35
+
36
+ if (!trimmed.endsWith(']')) return null
37
+
38
+ const open = trimmed.lastIndexOf('[')
39
+
40
+ if (open === -1) return null
41
+
42
+ const branch = trimmed.slice(open + 1, -1)
43
+
44
+ return branch.length > 0 ? branch : null
45
+ }
46
+
24
47
  /**
25
48
  * Extract a release branch name from a `git worktree list` output line.
26
49
  *
@@ -35,11 +58,9 @@ export const getCurrentWorktrees = async (type: 'release' | 'feature'): Promise<
35
58
  * // => null
36
59
  */
37
60
  const releaseWorktreePredicate = (line: string): string | null => {
38
- const parts = line.split(' ').filter(Boolean)
39
-
40
- if (parts.length < 3 || !parts[0]?.includes('release/v')) return null
61
+ const branch = parseWorktreeBranch(line)
41
62
 
42
- return `release/${parts[0]?.split('/').pop() || ''}`
63
+ return branch?.startsWith('release/v') ? branch : null
43
64
  }
44
65
 
45
66
  /**
@@ -56,11 +77,9 @@ const releaseWorktreePredicate = (line: string): string | null => {
56
77
  * // => null
57
78
  */
58
79
  const featureWorktreePredicate = (line: string): string | null => {
59
- const parts = line.split(' ').filter(Boolean)
60
-
61
- if (parts.length < 3 || !parts[0]?.includes('feature/')) return null
80
+ const branch = parseWorktreeBranch(line)
62
81
 
63
- return `feature/${parts[0]?.split('/').pop() || ''}`
82
+ return branch?.startsWith('feature/') ? branch : null
64
83
  }
65
84
 
66
85
  /**
@@ -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
  ]