infra-kit 0.1.88 → 0.1.90

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.88",
4
+ "version": "0.1.90",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/cli.js",
7
7
  "module": "dist/cli.js",
@@ -30,9 +30,9 @@
30
30
  "fix": "pnpm run prettier-fix && pnpm run eslint-fix && pnpm run qa"
31
31
  },
32
32
  "dependencies": {
33
- "@inquirer/checkbox": "^5.1.3",
34
- "@inquirer/confirm": "^6.0.11",
35
- "@inquirer/select": "^5.1.3",
33
+ "@inquirer/checkbox": "^5.1.4",
34
+ "@inquirer/confirm": "^6.0.12",
35
+ "@inquirer/select": "^5.1.4",
36
36
  "@modelcontextprotocol/sdk": "^1.29.0",
37
37
  "commander": "^14.0.3",
38
38
  "pino": "^10.3.1",
@@ -9,35 +9,27 @@ import {
9
9
  INFRA_KIT_ENV_CONFIG_VAR,
10
10
  INFRA_KIT_ENV_LOADED_AT_VAR,
11
11
  INFRA_KIT_ENV_PROJECT_VAR,
12
+ atomicWriteFileSync,
12
13
  getSessionCacheDir,
13
14
  parseVarNamesFromEnvFile,
14
15
  } from 'src/lib/constants'
15
- import { logger } from 'src/lib/logger'
16
16
  import type { ToolsExecutionResult } from 'src/types'
17
17
 
18
18
  /**
19
- * Clear loaded env vars. Prints a file path to stdout that must be sourced to apply. The env-clear shell alias does this automatically.
19
+ * Clear loaded env vars. Prints a file path to stdout that must be sourced to apply.
20
+ * The env-clear shell alias does this automatically. Throws when no env is loaded
21
+ * so CLI callers exit non-zero and MCP callers receive a structured tool error.
20
22
  */
21
23
  export const envClear = async (): Promise<ToolsExecutionResult> => {
22
24
  const cacheDir = getSessionCacheDir()
23
25
  const envLoadPath = path.join(cacheDir, ENV_LOAD_FILE)
24
26
 
25
27
  if (!fs.existsSync(envLoadPath)) {
26
- logger.error('No loaded environment found. Run `env-load` first.')
27
-
28
- return {
29
- content: [
30
- {
31
- type: 'text',
32
- text: 'No loaded environment found. Run `env-load` first.',
33
- },
34
- ],
35
- }
28
+ throw new Error('No loaded environment found. Run `env-load` first.')
36
29
  }
37
30
 
38
31
  const varNames = parseVarNamesFromEnvFile(envLoadPath)
39
32
 
40
- // Build unset script
41
33
  const unsetLines = [
42
34
  ...varNames.map((v) => {
43
35
  return `unset ${v}`
@@ -47,16 +39,16 @@ export const envClear = async (): Promise<ToolsExecutionResult> => {
47
39
  `unset ${INFRA_KIT_ENV_LOADED_AT_VAR}`,
48
40
  ]
49
41
 
50
- // Write unset script to cache
51
42
  const clearFilePath = path.resolve(cacheDir, ENV_CLEAR_FILE)
52
43
 
53
- fs.mkdirSync(cacheDir, { recursive: true })
54
- fs.writeFileSync(clearFilePath, `${unsetLines.join('\n')}\n`)
44
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
45
+
46
+ atomicWriteFileSync(clearFilePath, `${unsetLines.join('\n')}\n`, 0o600)
55
47
 
56
48
  // REQUIRED
57
49
  process.stdout.write(`${clearFilePath}\n`)
58
50
 
59
- // Remove env load file so env-clear can detect "no env loaded" next time
51
+ // Remove env load file so the next env-clear call correctly reports "no env loaded".
60
52
  fs.unlinkSync(envLoadPath)
61
53
 
62
54
  const structuredContent = {
@@ -1,17 +1,18 @@
1
1
  import { z } from 'zod'
2
2
 
3
- import { validateDopplerCliAndAuth } from 'src/integrations/doppler'
4
3
  import { getDopplerProject } from 'src/integrations/doppler/doppler-project'
5
4
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
6
5
  import { logger } from 'src/lib/logger'
7
6
  import type { ToolsExecutionResult } from 'src/types'
8
7
 
9
8
  /**
10
- * List available Doppler configs for the detected project
9
+ * List available Doppler configs for the detected project.
10
+ *
11
+ * Purely local: reads infra-kit.yml and does not call Doppler. We intentionally
12
+ * do not run validateDopplerCliAndAuth here — users listing envs often do so
13
+ * before `doppler login`, and a spurious auth error would be misleading.
11
14
  */
12
15
  export const envList = async (): Promise<ToolsExecutionResult> => {
13
- await validateDopplerCliAndAuth()
14
-
15
16
  const project = await getDopplerProject()
16
17
  const { environments } = await getInfraKitConfig()
17
18
 
@@ -42,7 +43,7 @@ export const envList = async (): Promise<ToolsExecutionResult> => {
42
43
  export const envListMcpTool = {
43
44
  name: 'env-list',
44
45
  description:
45
- 'List the environments the project is configured to support. Returns a static list defined in infra-kit constants (not a live fetch from Doppler) plus the Doppler project name auto-detected from the current directory. Read-only.',
46
+ 'List the environments the project is configured to support. Returns the `environments` list declared in infra-kit.yml at the project root (not a live fetch from Doppler) plus the Doppler project name resolved from the same file. Read-only.',
46
47
  inputSchema: {},
47
48
  outputSchema: {
48
49
  project: z.string().describe('Detected Doppler project name'),
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { DOPPLER_MAX_OUTPUT_BYTES, assertDopplerOutputSize, assertValidEnvContent, shellSingleQuote } from '../env-load'
4
+
5
+ describe('shellSingleQuote', () => {
6
+ it('wraps plain values in single quotes', () => {
7
+ expect(shellSingleQuote('dev')).toBe("'dev'")
8
+ })
9
+
10
+ it('escapes embedded single quotes using the posix idiom', () => {
11
+ expect(shellSingleQuote("it's")).toBe(String.raw`'it'\''s'`)
12
+ })
13
+
14
+ it('handles multiple single quotes', () => {
15
+ expect(shellSingleQuote("a'b'c")).toBe(String.raw`'a'\''b'\''c'`)
16
+ })
17
+
18
+ it('wraps empty strings as empty single quotes', () => {
19
+ expect(shellSingleQuote('')).toBe("''")
20
+ })
21
+
22
+ it('passes unicode through unchanged', () => {
23
+ expect(shellSingleQuote('café')).toBe("'café'")
24
+ })
25
+
26
+ it('does not alter backslashes (single-quoted shell strings treat them literally)', () => {
27
+ expect(shellSingleQuote(String.raw`a\b`)).toBe(String.raw`'a\b'`)
28
+ })
29
+ })
30
+
31
+ describe('assertValidEnvContent', () => {
32
+ it('throws on empty content', () => {
33
+ expect(() => {
34
+ return assertValidEnvContent('')
35
+ }).toThrow(/empty output/)
36
+ })
37
+
38
+ it('throws on whitespace-only content', () => {
39
+ expect(() => {
40
+ return assertValidEnvContent(' \n\n ')
41
+ }).toThrow(/empty output/)
42
+ })
43
+
44
+ it('accepts a single KEY=value line', () => {
45
+ expect(() => {
46
+ return assertValidEnvContent('FOO=bar')
47
+ }).not.toThrow()
48
+ })
49
+
50
+ it('accepts multiple KEY=value lines', () => {
51
+ expect(() => {
52
+ return assertValidEnvContent('FOO=bar\nBAZ=qux\nQUUX=123')
53
+ }).not.toThrow()
54
+ })
55
+
56
+ it('ignores `set -a` / `set +a` directives', () => {
57
+ expect(() => {
58
+ return assertValidEnvContent('set -a\nFOO=bar\nset +a')
59
+ }).not.toThrow()
60
+ })
61
+
62
+ it('ignores blank lines between KEY=value lines', () => {
63
+ expect(() => {
64
+ return assertValidEnvContent('FOO=bar\n\nBAZ=qux')
65
+ }).not.toThrow()
66
+ })
67
+
68
+ it('throws when first non-blank line is not KEY=value', () => {
69
+ expect(() => {
70
+ return assertValidEnvContent('<html>error</html>\nFOO=bar')
71
+ }).toThrow(/unexpected output/)
72
+ })
73
+
74
+ it('throws when a later line is garbage (not only first-line validation)', () => {
75
+ expect(() => {
76
+ return assertValidEnvContent('FOO=bar\n<html>error</html>')
77
+ }).toThrow(/unexpected output/)
78
+ })
79
+
80
+ it('throws when a line looks like a shell command rather than an assignment', () => {
81
+ expect(() => {
82
+ return assertValidEnvContent('FOO=bar\nunset BAR')
83
+ }).toThrow(/unexpected output/)
84
+ })
85
+
86
+ it('accepts values containing = characters (connection strings)', () => {
87
+ expect(() => {
88
+ return assertValidEnvContent('DB_URL=host=db;user=admin\nFOO=bar')
89
+ }).not.toThrow()
90
+ })
91
+ })
92
+
93
+ describe('assertDopplerOutputSize', () => {
94
+ it('accepts empty output (shape is validated separately)', () => {
95
+ expect(() => {
96
+ return assertDopplerOutputSize('')
97
+ }).not.toThrow()
98
+ })
99
+
100
+ it('accepts typical-sized output', () => {
101
+ expect(() => {
102
+ return assertDopplerOutputSize('FOO=bar\n'.repeat(100))
103
+ }).not.toThrow()
104
+ })
105
+
106
+ it('accepts output exactly at the cap', () => {
107
+ const atCap = 'x'.repeat(DOPPLER_MAX_OUTPUT_BYTES)
108
+
109
+ expect(() => {
110
+ return assertDopplerOutputSize(atCap)
111
+ }).not.toThrow()
112
+ })
113
+
114
+ it('rejects output one byte over the cap', () => {
115
+ const overCap = 'x'.repeat(DOPPLER_MAX_OUTPUT_BYTES + 1)
116
+
117
+ expect(() => {
118
+ return assertDopplerOutputSize(overCap)
119
+ }).toThrow(/unexpectedly large output/)
120
+ })
121
+
122
+ it('counts bytes not characters (multi-byte unicode)', () => {
123
+ // "💥" is 4 bytes in UTF-8; N copies = 4N bytes. Pick N so bytes > cap but chars < cap.
124
+ const chars = Math.ceil(DOPPLER_MAX_OUTPUT_BYTES / 4) + 1
125
+ const multiByte = '💥'.repeat(chars)
126
+
127
+ expect(multiByte.length).toBeLessThan(DOPPLER_MAX_OUTPUT_BYTES)
128
+ expect(() => {
129
+ return assertDopplerOutputSize(multiByte)
130
+ }).toThrow(/unexpectedly large output/)
131
+ })
132
+ })
@@ -1,4 +1,5 @@
1
1
  import select from '@inquirer/select'
2
+ import { Buffer } from 'node:buffer'
2
3
  import fs from 'node:fs'
3
4
  import path from 'node:path'
4
5
  import process from 'node:process'
@@ -10,9 +11,11 @@ import { getDopplerProject } from 'src/integrations/doppler/doppler-project'
10
11
  import { commandEcho } from 'src/lib/command-echo'
11
12
  import {
12
13
  ENV_LOAD_FILE,
14
+ ENV_VAR_LINE_PATTERN,
13
15
  INFRA_KIT_ENV_CONFIG_VAR,
14
16
  INFRA_KIT_ENV_LOADED_AT_VAR,
15
17
  INFRA_KIT_ENV_PROJECT_VAR,
18
+ atomicWriteFileSync,
16
19
  getSessionCacheDir,
17
20
  } from 'src/lib/constants'
18
21
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
@@ -57,37 +60,35 @@ export const envLoad = async (args: EnvLoadArgs): Promise<ToolsExecutionResult>
57
60
 
58
61
  const project = await getDopplerProject()
59
62
 
60
- $.quiet = true
61
- const result =
62
- await $`doppler secrets download --no-file --format env --project ${project} --config ${selectedConfig}`
63
+ const envContent = await downloadDopplerSecrets(project, selectedConfig)
63
64
 
64
- $.quiet = false
65
- const envContent = result.stdout.trim()
65
+ assertValidEnvContent(envContent)
66
66
 
67
67
  // Build env file content in dotenv format
68
68
  const loadedAt = new Date().toISOString()
69
69
  const envFileLines = [
70
70
  'set -a',
71
71
  envContent,
72
- `${INFRA_KIT_ENV_CONFIG_VAR}=${selectedConfig}`,
73
- `${INFRA_KIT_ENV_PROJECT_VAR}=${project}`,
74
- `${INFRA_KIT_ENV_LOADED_AT_VAR}=${loadedAt}`,
72
+ `${INFRA_KIT_ENV_CONFIG_VAR}=${shellSingleQuote(selectedConfig)}`,
73
+ `${INFRA_KIT_ENV_PROJECT_VAR}=${shellSingleQuote(project)}`,
74
+ `${INFRA_KIT_ENV_LOADED_AT_VAR}=${shellSingleQuote(loadedAt)}`,
75
75
  'set +a',
76
76
  ]
77
77
 
78
- // Write env file to cache
79
78
  const cacheDir = getSessionCacheDir()
80
79
  const envFilePath = path.resolve(cacheDir, ENV_LOAD_FILE)
81
80
 
82
- fs.mkdirSync(cacheDir, { recursive: true })
83
- fs.writeFileSync(envFilePath, `${envFileLines.join('\n')}\n`)
81
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
82
+ atomicWriteFileSync(envFilePath, `${envFileLines.join('\n')}\n`, 0o600)
84
83
 
85
84
  // REQUIRED
86
85
  process.stdout.write(`${envFilePath}\n`)
87
86
 
88
- const varCount = envContent.split('\n').filter((line) => {
89
- return line.includes('=')
90
- }).length
87
+ // Logs to stderr (pino → pretty-print), so it doesn't pollute the captured
88
+ // file path that the shell wrapper reads from stdout.
89
+ commandEcho.print()
90
+
91
+ const varCount = countEnvVarLines(envContent)
91
92
 
92
93
  const structuredContent = {
93
94
  filePath: envFilePath,
@@ -107,6 +108,86 @@ export const envLoad = async (args: EnvLoadArgs): Promise<ToolsExecutionResult>
107
108
  }
108
109
  }
109
110
 
111
+ /**
112
+ * Cap the Doppler stdout we're willing to accept. A well-formed env bundle is
113
+ * O(10 KB); megabytes would indicate a service regression or the wrong stream
114
+ * being captured, and we don't want to write that to disk or source it.
115
+ */
116
+ export const DOPPLER_MAX_OUTPUT_BYTES = 1024 * 1024
117
+
118
+ /**
119
+ * Hard upper bound for the Doppler subprocess. Well under zx's default so a
120
+ * hung call surfaces quickly instead of blocking an interactive shell or an
121
+ * MCP tool handler.
122
+ */
123
+ const DOPPLER_DOWNLOAD_TIMEOUT_MS = 30_000
124
+
125
+ const downloadDopplerSecrets = async (project: string, config: string): Promise<string> => {
126
+ const prevQuiet = $.quiet
127
+
128
+ $.quiet = true
129
+ try {
130
+ const result =
131
+ await $`doppler secrets download --no-file --format env --project ${project} --config ${config}`.timeout(
132
+ DOPPLER_DOWNLOAD_TIMEOUT_MS,
133
+ )
134
+
135
+ assertDopplerOutputSize(result.stdout)
136
+
137
+ return result.stdout.trim()
138
+ } finally {
139
+ $.quiet = prevQuiet
140
+ }
141
+ }
142
+
143
+ export const assertDopplerOutputSize = (stdout: string): void => {
144
+ const bytes = Buffer.byteLength(stdout, 'utf-8')
145
+
146
+ if (bytes > DOPPLER_MAX_OUTPUT_BYTES) {
147
+ throw new Error(
148
+ `doppler returned unexpectedly large output (${bytes} bytes > ${DOPPLER_MAX_OUTPUT_BYTES}) — refusing to write to disk`,
149
+ )
150
+ }
151
+ }
152
+
153
+ const countEnvVarLines = (content: string): number => {
154
+ return content.split('\n').filter((line) => {
155
+ return ENV_VAR_LINE_PATTERN.test(line)
156
+ }).length
157
+ }
158
+
159
+ const SHELL_DIRECTIVE_LINES = new Set(['set -a', 'set +a'])
160
+
161
+ export const shellSingleQuote = (value: string): string => {
162
+ const escaped = value.replaceAll("'", "'\\''")
163
+
164
+ return `'${escaped}'`
165
+ }
166
+
167
+ /**
168
+ * Guard against Doppler returning non-env output (auth warnings on stdout,
169
+ * partial downloads, HTML error pages, etc.). Every non-blank, non-directive
170
+ * line must match KEY=VALUE — skipping directives keeps future format tweaks
171
+ * cheap without loosening the check.
172
+ */
173
+ export const assertValidEnvContent = (content: string): void => {
174
+ if (content.trim().length === 0) {
175
+ throw new Error('doppler returned empty output for env-load')
176
+ }
177
+
178
+ for (const line of content.split('\n')) {
179
+ const trimmed = line.trim()
180
+
181
+ if (trimmed.length === 0 || SHELL_DIRECTIVE_LINES.has(trimmed)) continue
182
+
183
+ if (!ENV_VAR_LINE_PATTERN.test(trimmed)) {
184
+ throw new Error(
185
+ `doppler returned unexpected output for env-load (expected KEY=value lines, got: ${JSON.stringify(trimmed.slice(0, 80))})`,
186
+ )
187
+ }
188
+ }
189
+ }
190
+
110
191
  // MCP Tool Registration
111
192
  export const envLoadMcpTool = {
112
193
  name: 'env-load',
@@ -51,6 +51,14 @@ export const envStatus = async (): Promise<ToolsExecutionResult> => {
51
51
  logger.info(
52
52
  ` ${sessionConfig}: ${sessionLoadedCount} of ${sessionTotalCount} vars loaded (project: ${sessionProject}, loadedAt: ${loadedAtDisplay}, session: ${sessionId})\n`,
53
53
  )
54
+
55
+ if (sessionTotalCount > 0 && sessionLoadedCount < sessionTotalCount) {
56
+ const missing = sessionTotalCount - sessionLoadedCount
57
+
58
+ logger.warn(
59
+ ` ${missing} cached var(s) are not present in the current process — env-load needs to be re-sourced, or vars were unset manually.`,
60
+ )
61
+ }
54
62
  } else {
55
63
  logger.info(` Session ${sessionId}: no env loaded\n`)
56
64
  }
@@ -38,7 +38,6 @@ const isBlockLine = (line: string): boolean => {
38
38
  line.startsWith('env-status') ||
39
39
  line.startsWith('if ') ||
40
40
  line.startsWith(' export INFRA_KIT_SESSION') ||
41
- line.startsWith('export _INFRA_KIT_LAST_') ||
42
41
  line.startsWith('export _INFRA_KIT_') ||
43
42
  line.startsWith(': ${_INFRA_KIT_') ||
44
43
  line.startsWith('fi') ||
@@ -113,13 +112,15 @@ const buildShellBlock = (): string => {
113
112
  // eslint-disable-next-line no-template-curly-in-string
114
113
  ': ${_INFRA_KIT_SHELL_STARTED:=${EPOCHSECONDS:-0}}',
115
114
  'export _INFRA_KIT_LAST_LOAD_MTIME _INFRA_KIT_LAST_CLEAR_MTIME _INFRA_KIT_SHELL_STARTED',
116
- `env-load() { local f; f=$(${runCmd} env-load "$@") && source "$f" && _INFRA_KIT_LAST_LOAD_MTIME=$(zstat +mtime -- "$f" 2>/dev/null || echo 0) && ${runCmd} env-status; }`,
117
- `env-clear() { local f; f=$(${runCmd} env-clear) && source "$f" && _INFRA_KIT_LAST_CLEAR_MTIME=$(zstat +mtime -- "$f" 2>/dev/null || echo 0) && ${runCmd} env-status; }`,
115
+ `env-load() { local f m; f=$(${runCmd} env-load "$@") || return; m=$(zstat +mtime -- "$f" 2>/dev/null || echo 0); _INFRA_KIT_LAST_LOAD_MTIME=$m; source "$f"; ${runCmd} env-status; }`,
116
+ `env-clear() { local f m; f=$(${runCmd} env-clear) || return; m=$(zstat +mtime -- "$f" 2>/dev/null || echo 0); _INFRA_KIT_LAST_CLEAR_MTIME=$m; source "$f"; ${runCmd} env-status; }`,
118
117
  `env-status() { ${runCmd} env-status; }`,
119
118
  `alias ik='${runCmd}'`,
120
119
  '_infra_kit_autoload() {',
121
120
  ' [[ -z "$INFRA_KIT_SESSION" ]] && return',
122
- ' local dir="./node_modules/.cache/infra-kit/$INFRA_KIT_SESSION"',
121
+ // eslint-disable-next-line no-template-curly-in-string
122
+ ' local cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/infra-kit"',
123
+ ' local dir="$cache_root/$INFRA_KIT_SESSION"',
123
124
  ' local load_file="$dir/env-load.sh"',
124
125
  ' local clear_file="$dir/env-clear.sh"',
125
126
  ' local mtime',
@@ -142,7 +143,9 @@ const buildShellBlock = (): string => {
142
143
  ' fi',
143
144
  '}',
144
145
  'autoload -Uz add-zsh-hook',
145
- 'add-zsh-hook precmd _infra_kit_autoload',
146
+ 'if (( _INFRA_KIT_SHELL_STARTED > 0 )); then',
147
+ ' add-zsh-hook precmd _infra_kit_autoload',
148
+ 'fi',
146
149
  MARKER_END,
147
150
  ].join('\n')
148
151
  }
@@ -5,10 +5,11 @@ import process from 'node:process'
5
5
  import { z } from 'zod'
6
6
  import { $ } from 'zx'
7
7
 
8
+ import { openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
8
9
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
9
10
  import { commandEcho } from 'src/lib/command-echo'
10
11
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
- import { getCurrentWorktrees, getProjectRoot } from 'src/lib/git-utils'
12
+ import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
12
13
  import { logger } from 'src/lib/logger'
13
14
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
14
15
  import type { ReleaseType } from 'src/lib/release-utils'
@@ -24,6 +25,7 @@ interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
24
25
  versions?: string
25
26
  cursor?: boolean
26
27
  githubDesktop?: boolean
28
+ cmux?: boolean
27
29
  }
28
30
 
29
31
  /**
@@ -31,7 +33,7 @@ interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
31
33
  * Creates worktrees for active release branches and removes unused ones
32
34
  */
33
35
  export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<ToolsExecutionResult> => {
34
- const { confirmedCommand, all, versions, cursor, githubDesktop } = options
36
+ const { confirmedCommand, all, versions, cursor, githubDesktop, cmux } = options
35
37
 
36
38
  commandEcho.start('worktrees-add')
37
39
 
@@ -147,6 +149,18 @@ export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<Too
147
149
  commandEcho.addOption('--no-github-desktop', true)
148
150
  }
149
151
 
152
+ const openInCmux = cmux ?? (await confirm({ message: 'Open created worktrees in cmux?' }))
153
+
154
+ if (typeof cmux === 'undefined') {
155
+ commandEcho.setInteractive()
156
+ }
157
+
158
+ if (openInCmux) {
159
+ commandEcho.addOption('--cmux', true)
160
+ } else {
161
+ commandEcho.addOption('--no-cmux', true)
162
+ }
163
+
150
164
  const { branchesToCreate } = categorizeWorktrees({
151
165
  selectedReleaseBranches,
152
166
  currentWorktrees,
@@ -169,6 +183,19 @@ export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<Too
169
183
  }
170
184
  }
171
185
 
186
+ if (openInCmux) {
187
+ const repoName = await getRepoName()
188
+
189
+ for (const branch of createdWorktrees) {
190
+ const version = branch.replace('release/', '')
191
+
192
+ await openCmuxWorkspaceWithLayout({
193
+ cwd: `${worktreeDir}/${branch}`,
194
+ title: `${repoName} ${version}`,
195
+ })
196
+ }
197
+ }
198
+
172
199
  commandEcho.print()
173
200
 
174
201
  const structuredContent = {
@@ -269,7 +296,7 @@ const logResults = (created: string[]): void => {
269
296
  export const worktreesAddMcpTool = {
270
297
  name: 'worktrees-add',
271
298
  description:
272
- 'Create local git worktrees for release branches under the worktrees directory and run "pnpm install" in each. Mutates the local filesystem. When invoked via MCP, pass either "versions" (comma-separated) or all=true — the branch picker and "open in Cursor / GitHub Desktop" follow-up prompts are unreachable without a TTY, and the CLI confirmation is auto-skipped for MCP calls.',
299
+ 'Create local git worktrees for release branches under the worktrees directory and run "pnpm install" in each. Mutates the local filesystem. When invoked via MCP, pass either "versions" (comma-separated) or all=true — the branch picker and "open in Cursor / GitHub Desktop / cmux" follow-up prompts are unreachable without a TTY, and the CLI confirmation is auto-skipped for MCP calls.',
273
300
  inputSchema: {
274
301
  all: z
275
302
  .boolean()
@@ -295,6 +322,12 @@ export const worktreesAddMcpTool = {
295
322
  .describe(
296
323
  'Open each created worktree in GitHub Desktop. Defaults to false in MCP mode (the follow-up prompt is not shown).',
297
324
  ),
325
+ cmux: z
326
+ .boolean()
327
+ .optional()
328
+ .describe(
329
+ 'Open each created worktree in a new cmux workspace with a 3-pane layout (left-top, left-bottom, full-height right), all rooted at the worktree directory. Defaults to false in MCP mode (the follow-up prompt is not shown).',
330
+ ),
298
331
  },
299
332
  outputSchema: {
300
333
  createdWorktrees: z.array(z.string()).describe('List of created git worktree branches'),
package/src/entry/cli.ts CHANGED
@@ -19,9 +19,25 @@ import { worktreesAdd } from 'src/commands/worktrees-add'
19
19
  import { worktreesList } from 'src/commands/worktrees-list'
20
20
  import { worktreesRemove } from 'src/commands/worktrees-remove'
21
21
  import { worktreesSync } from 'src/commands/worktrees-sync'
22
+ import { logger } from 'src/lib/logger'
22
23
 
23
24
  const program = new Command()
24
25
 
26
+ const runProgram = async (argv?: string[]): Promise<void> => {
27
+ try {
28
+ if (argv) {
29
+ await program.parseAsync(argv)
30
+ } else {
31
+ await program.parseAsync()
32
+ }
33
+ } catch (error) {
34
+ const message = error instanceof Error ? error.message : String(error)
35
+
36
+ logger.error(message)
37
+ process.exit(1)
38
+ }
39
+ }
40
+
25
41
  program
26
42
  .command('merge-dev')
27
43
  .description('Merge dev branch into every release branch')
@@ -123,6 +139,8 @@ program
123
139
  .option('--no-cursor', 'Skip Cursor prompt')
124
140
  .option('-g, --github-desktop', 'Open created worktrees in GitHub Desktop')
125
141
  .option('--no-github-desktop', 'Skip GitHub Desktop prompt')
142
+ .option('-m, --cmux', 'Open created worktrees in cmux (3-pane layout)')
143
+ .option('--no-cmux', 'Skip cmux prompt')
126
144
  .action(async (options) => {
127
145
  await worktreesAdd({
128
146
  confirmedCommand: options.yes,
@@ -130,6 +148,7 @@ program
130
148
  versions: options.versions,
131
149
  cursor: options.cursor,
132
150
  githubDesktop: options.githubDesktop,
151
+ cmux: options.cmux,
133
152
  })
134
153
  })
135
154
 
@@ -250,7 +269,7 @@ if (process.argv.length <= 2) {
250
269
  { output: process.stderr },
251
270
  )
252
271
 
253
- program.parse(['node', 'infra-kit', selected])
272
+ await runProgram(['node', 'infra-kit', selected])
254
273
  } else {
255
- program.parse()
274
+ await runProgram()
256
275
  }
@@ -0,0 +1 @@
1
+ export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
@@ -0,0 +1,41 @@
1
+ import { $ } from 'zx'
2
+
3
+ interface OpenCmuxWorkspaceArgs {
4
+ cwd: string
5
+ title?: string
6
+ }
7
+
8
+ /**
9
+ * Opens a new cmux workspace rooted at `cwd` with three panes:
10
+ * left-top (primary) | right (full-height)
11
+ * left-bottom |
12
+ * All panes inherit `cwd` from the workspace.
13
+ */
14
+ export const openCmuxWorkspaceWithLayout = async (args: OpenCmuxWorkspaceArgs): Promise<void> => {
15
+ const { cwd, title } = args
16
+
17
+ await $`cmux new-workspace --cwd ${cwd}`
18
+
19
+ const workspaceRef = (await $`cmux current-workspace`).stdout.trim()
20
+
21
+ const surfacesOutput = (await $`cmux list-pane-surfaces --workspace ${workspaceRef}`).stdout
22
+
23
+ const leftTopRef = parseFirstSurfaceRef(surfacesOutput)
24
+
25
+ await $`cmux new-split right --workspace ${workspaceRef} --surface ${leftTopRef}`
26
+ await $`cmux new-split down --workspace ${workspaceRef} --surface ${leftTopRef}`
27
+
28
+ if (title) {
29
+ await $`cmux rename-workspace --workspace ${workspaceRef} ${title}`
30
+ }
31
+ }
32
+
33
+ const parseFirstSurfaceRef = (output: string): string => {
34
+ const match = output.match(/surface:\d+/)
35
+
36
+ if (!match) {
37
+ throw new Error('cmux: could not locate initial surface in list-pane-surfaces output')
38
+ }
39
+
40
+ return match[0]
41
+ }