infra-kit 0.1.124 → 0.1.126

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 (45) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-2ccbd177-0d5d-4d4a-ab94-a22464786221.jsonl +1 -0
  3. package/.omc/state/agent-replay-eb44055b-c2fa-4e66-b491-a1d9b44f51c7.jsonl +1 -0
  4. package/.omc/state/idle-notif-cooldown.json +1 -1
  5. package/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/pre-tool-advisory-throttle.json +18 -0
  6. package/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/subagent-tracking-state.json +17 -0
  7. package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/last-tool-error-state.json +7 -0
  8. package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/pre-tool-advisory-throttle.json +18 -0
  9. package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/subagent-tracking-state.json +17 -0
  10. package/.turbo/turbo-build.log +6 -6
  11. package/.turbo/turbo-eslint-check.log +1 -1
  12. package/.turbo/turbo-prettier-check.log +1 -1
  13. package/.turbo/turbo-test.log +128 -127
  14. package/.turbo/turbo-ts-check.log +1 -1
  15. package/dist/chunk-EY5LDHXC.js +164 -0
  16. package/dist/chunk-EY5LDHXC.js.map +7 -0
  17. package/dist/cli.js +6 -6
  18. package/dist/cli.js.map +3 -3
  19. package/dist/mcp.js +1 -1
  20. package/package.json +1 -1
  21. package/src/.omc/state/agent-replay-2ccbd177-0d5d-4d4a-ab94-a22464786221.jsonl +2 -0
  22. package/src/.omc/state/idle-notif-cooldown.json +1 -1
  23. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/last-tool-error-state.json +7 -0
  24. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/mission-state.json +53 -0
  25. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/pre-tool-advisory-throttle.json +22 -0
  26. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/subagent-tracking-state.json +17 -0
  27. package/src/commands/env-clear/env-clear.ts +4 -1
  28. package/src/commands/env-load/__tests__/env-load.test.ts +99 -66
  29. package/src/commands/env-load/env-load.ts +87 -35
  30. package/src/commands/init/__tests__/shell-body.test.ts +4 -4
  31. package/src/commands/init/init.ts +23 -24
  32. package/src/commands/worktrees-add/worktrees-add.ts +6 -0
  33. package/src/commands/worktrees-remove/worktrees-remove.ts +6 -0
  34. package/src/commands/worktrees-sync/worktrees-sync.ts +6 -0
  35. package/src/entry/cli.ts +60 -45
  36. package/src/lib/constants/__tests__/constants.test.ts +22 -0
  37. package/src/lib/constants/constants.ts +40 -4
  38. package/src/lib/constants/index.ts +1 -0
  39. package/src/lib/env-autoload/__tests__/env-autoload.test.ts +15 -1
  40. package/src/lib/env-autoload/env-autoload.ts +54 -6
  41. package/src/lib/errors/__tests__/is-prompt-cancellation.test.ts +48 -0
  42. package/src/lib/errors/is-prompt-cancellation.ts +38 -0
  43. package/tsconfig.tsbuildinfo +1 -1
  44. package/dist/chunk-W4QG2W7G.js +0 -166
  45. package/dist/chunk-W4QG2W7G.js.map +0 -7
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": 1,
3
+ "entries": {
4
+ "466399dafa2d20f60587180bad0a07358eb7f2bce724df0ff682f038ad33f5ad": {
5
+ "last_emitted_at_ms": 1782817961585,
6
+ "message": "Read multiple files in parallel when possible for faster analysis."
7
+ },
8
+ "445ed27a3872b681d98190bae61ccb84954e1bc4e140df5370be958dee776b3a": {
9
+ "last_emitted_at_ms": 1782817799580,
10
+ "message": "Verify changes work after editing. Test functionality before marking complete."
11
+ },
12
+ "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
13
+ "last_emitted_at_ms": 1782817772318,
14
+ "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
15
+ },
16
+ "a353cb6bbc54e52818d87f20d3e405a3b424eea973fb85201083ef0dac4defab": {
17
+ "last_emitted_at_ms": 1782817459860,
18
+ "message": "Spawning agent: oh-my-claudecode:executor (opus) | Task: Implement env-autoload bug fixes"
19
+ }
20
+ },
21
+ "updated_at": "2026-06-30T11:12:41.585Z"
22
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "agents": [
3
+ {
4
+ "agent_id": "aa0bc2493749f11ee",
5
+ "agent_type": "oh-my-claudecode:executor",
6
+ "started_at": "2026-06-30T11:04:20.002Z",
7
+ "parent_mode": "none",
8
+ "status": "completed",
9
+ "completed_at": "2026-06-30T11:13:43.458Z",
10
+ "duration_ms": 563456
11
+ }
12
+ ],
13
+ "total_spawned": 1,
14
+ "total_completed": 1,
15
+ "total_failed": 0,
16
+ "last_updated": "2026-06-30T11:13:43.560Z"
17
+ }
@@ -10,6 +10,7 @@ import {
10
10
  INFRA_KIT_ENV_CLEARED_VAR,
11
11
  INFRA_KIT_ENV_CONFIG_VAR,
12
12
  INFRA_KIT_ENV_LOADED_AT_VAR,
13
+ INFRA_KIT_ENV_PROJECT_ROOT_VAR,
13
14
  INFRA_KIT_ENV_PROJECT_VAR,
14
15
  atomicWriteFileSync,
15
16
  getSessionCacheDir,
@@ -30,6 +31,7 @@ export const buildEnvClearLines = (varNames: string[]): string[] => {
30
31
  }),
31
32
  `unset ${INFRA_KIT_ENV_CONFIG_VAR}`,
32
33
  `unset ${INFRA_KIT_ENV_PROJECT_VAR}`,
34
+ `unset ${INFRA_KIT_ENV_PROJECT_ROOT_VAR}`,
33
35
  `unset ${INFRA_KIT_ENV_LOADED_AT_VAR}`,
34
36
  `unset ${INFRA_KIT_ENV_AUTOLOADED_VAR}`,
35
37
  `export ${INFRA_KIT_ENV_CLEARED_VAR}='1'`,
@@ -63,7 +65,8 @@ export const envClear = async () => {
63
65
  process.stdout.write(`${clearFilePath}\n`)
64
66
 
65
67
  // Remove env load file so the next env-clear call correctly reports "no env loaded".
66
- fs.unlinkSync(envLoadPath)
68
+ // `force` so concurrent clears don't throw ENOENT when another already removed it.
69
+ fs.rmSync(envLoadPath, { force: true })
67
70
 
68
71
  const structuredContent = {
69
72
  filePath: clearFilePath,
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'
3
3
  import {
4
4
  DOPPLER_MAX_OUTPUT_BYTES,
5
5
  assertDopplerOutputSize,
6
- assertValidEnvContent,
7
6
  buildEnvLoadFileLines,
7
+ parseDopplerSecretsJson,
8
8
  shellSingleQuote,
9
9
  } from '../env-load'
10
10
 
@@ -34,68 +34,6 @@ describe('shellSingleQuote', () => {
34
34
  })
35
35
  })
36
36
 
37
- describe('assertValidEnvContent', () => {
38
- it('throws on empty content', () => {
39
- expect(() => {
40
- return assertValidEnvContent('')
41
- }).toThrow(/empty output/)
42
- })
43
-
44
- it('throws on whitespace-only content', () => {
45
- expect(() => {
46
- return assertValidEnvContent(' \n\n ')
47
- }).toThrow(/empty output/)
48
- })
49
-
50
- it('accepts a single KEY=value line', () => {
51
- expect(() => {
52
- return assertValidEnvContent('FOO=bar')
53
- }).not.toThrow()
54
- })
55
-
56
- it('accepts multiple KEY=value lines', () => {
57
- expect(() => {
58
- return assertValidEnvContent('FOO=bar\nBAZ=qux\nQUUX=123')
59
- }).not.toThrow()
60
- })
61
-
62
- it('ignores `set -a` / `set +a` directives', () => {
63
- expect(() => {
64
- return assertValidEnvContent('set -a\nFOO=bar\nset +a')
65
- }).not.toThrow()
66
- })
67
-
68
- it('ignores blank lines between KEY=value lines', () => {
69
- expect(() => {
70
- return assertValidEnvContent('FOO=bar\n\nBAZ=qux')
71
- }).not.toThrow()
72
- })
73
-
74
- it('throws when first non-blank line is not KEY=value', () => {
75
- expect(() => {
76
- return assertValidEnvContent('<html>error</html>\nFOO=bar')
77
- }).toThrow(/unexpected output/)
78
- })
79
-
80
- it('throws when a later line is garbage (not only first-line validation)', () => {
81
- expect(() => {
82
- return assertValidEnvContent('FOO=bar\n<html>error</html>')
83
- }).toThrow(/unexpected output/)
84
- })
85
-
86
- it('throws when a line looks like a shell command rather than an assignment', () => {
87
- expect(() => {
88
- return assertValidEnvContent('FOO=bar\nunset BAR')
89
- }).toThrow(/unexpected output/)
90
- })
91
-
92
- it('accepts values containing = characters (connection strings)', () => {
93
- expect(() => {
94
- return assertValidEnvContent('DB_URL=host=db;user=admin\nFOO=bar')
95
- }).not.toThrow()
96
- })
97
- })
98
-
99
37
  describe('assertDopplerOutputSize', () => {
100
38
  it('accepts empty output (shape is validated separately)', () => {
101
39
  expect(() => {
@@ -138,15 +76,27 @@ describe('assertDopplerOutputSize', () => {
138
76
  })
139
77
 
140
78
  describe('buildEnvLoadFileLines', () => {
141
- const args = { envContent: 'A=1\nB=2', config: 'dev', project: 'proj', loadedAt: '2026-01-01T00:00:00.000Z' }
142
-
143
- it('wraps the body in set -a / set +a and records config/project/loadedAt', () => {
79
+ const args = {
80
+ pairs: [
81
+ ['A', '1'],
82
+ ['B', '2'],
83
+ ] as Array<[string, string]>,
84
+ config: 'dev',
85
+ project: 'proj',
86
+ projectRoot: '/repo',
87
+ loadedAt: '2026-01-01T00:00:00.000Z',
88
+ }
89
+
90
+ it('wraps the body in set -a / set +a and records config/project/projectRoot/loadedAt', () => {
144
91
  const lines = buildEnvLoadFileLines({ ...args, autoLoaded: false })
145
92
 
146
93
  expect(lines[0]).toBe('set -a')
147
94
  expect(lines.at(-1)).toBe('set +a')
95
+ expect(lines).toContain("A='1'")
96
+ expect(lines).toContain("B='2'")
148
97
  expect(lines).toContain("INFRA_KIT_ENV_CONFIG='dev'")
149
98
  expect(lines).toContain("INFRA_KIT_ENV_PROJECT='proj'")
99
+ expect(lines).toContain("INFRA_KIT_ENV_PROJECT_ROOT='/repo'")
150
100
  expect(lines).toContain("INFRA_KIT_ENV_LOADED_AT='2026-01-01T00:00:00.000Z'")
151
101
  })
152
102
 
@@ -165,3 +115,86 @@ describe('buildEnvLoadFileLines', () => {
165
115
  expect(lines).not.toContain("INFRA_KIT_ENV_AUTOLOADED='1'")
166
116
  })
167
117
  })
118
+
119
+ describe('buildEnvLoadFileLines — injection neutralization (single-quoting)', () => {
120
+ const baseArgs = {
121
+ config: 'dev',
122
+ project: 'proj',
123
+ projectRoot: '/repo',
124
+ loadedAt: '2026-01-01T00:00:00.000Z',
125
+ autoLoaded: false as const,
126
+ }
127
+
128
+ it('single-quotes shell-active values so nothing expands or executes on source', () => {
129
+ const pairs: Array<[string, string]> = [
130
+ ['CMD', 'a$(id)b'],
131
+ ['VAR', 'x$HOME'],
132
+ ['TICK', '`whoami`'],
133
+ ['Q', "it's"],
134
+ ['NL', 'line1\nline2'],
135
+ ]
136
+
137
+ const lines = buildEnvLoadFileLines({ ...baseArgs, pairs })
138
+
139
+ // Each value line is exactly KEY=<posix-single-quoted value>.
140
+ expect(lines).toContain("CMD='a$(id)b'")
141
+ expect(lines).toContain("VAR='x$HOME'")
142
+ expect(lines).toContain("TICK='`whoami`'")
143
+ expect(lines).toContain(String.raw`Q='it'\''s'`)
144
+ // The newline stays literal inside the single quotes.
145
+ expect(lines).toContain("NL='line1\nline2'")
146
+
147
+ // The load-bearing guarantee: no emitted value line carries an unescaped
148
+ // command substitution, backtick, or a double-quote-wrapped value.
149
+ const valueLines = lines.filter((line) => {
150
+ return /^(?:CMD|VAR|TICK|Q|NL)=/.test(line)
151
+ })
152
+
153
+ for (const line of valueLines) {
154
+ const value = line.slice(line.indexOf('=') + 1)
155
+
156
+ expect(value.startsWith("'")).toBe(true)
157
+ expect(value.endsWith("'")).toBe(true)
158
+ expect(value).not.toMatch(/^"/)
159
+ }
160
+ })
161
+ })
162
+
163
+ describe('parseDopplerSecretsJson', () => {
164
+ it('parses a flat KEY:value object into ordered pairs (values returned raw)', () => {
165
+ expect(parseDopplerSecretsJson('{"FOO":"bar","BAZ":"a$(x)"}')).toEqual([
166
+ ['FOO', 'bar'],
167
+ ['BAZ', 'a$(x)'],
168
+ ])
169
+ })
170
+
171
+ it('throws on non-JSON output', () => {
172
+ expect(() => {
173
+ return parseDopplerSecretsJson('<html>nope</html>')
174
+ }).toThrow(/non-JSON output/)
175
+ })
176
+
177
+ it('throws on a JSON array (wrong shape)', () => {
178
+ expect(() => {
179
+ return parseDopplerSecretsJson('["FOO","bar"]')
180
+ }).toThrow(/unexpected JSON shape/)
181
+ })
182
+
183
+ it('throws on an empty object', () => {
184
+ expect(() => {
185
+ return parseDopplerSecretsJson('{}')
186
+ }).toThrow(/empty output/)
187
+ })
188
+
189
+ it('throws on an invalid env var name', () => {
190
+ expect(() => {
191
+ return parseDopplerSecretsJson('{"1BAD":"x"}')
192
+ }).toThrow(/invalid env var name/)
193
+ })
194
+
195
+ it('throws on a non-string value', () => {
196
+ expect(() => {
197
+ return parseDopplerSecretsJson('{"FOO":123}')
198
+ }).toThrow(/non-string value/)
199
+ })
200
+ })
@@ -17,16 +17,17 @@ import {
17
17
  import { commandEcho } from 'src/lib/command-echo'
18
18
  import {
19
19
  ENV_LOAD_FILE,
20
- ENV_VAR_LINE_PATTERN,
21
20
  INFRA_KIT_ENV_AUTOLOADED_VAR,
22
21
  INFRA_KIT_ENV_CLEARED_VAR,
23
22
  INFRA_KIT_ENV_CONFIG_VAR,
24
23
  INFRA_KIT_ENV_LOADED_AT_VAR,
24
+ INFRA_KIT_ENV_PROJECT_ROOT_VAR,
25
25
  INFRA_KIT_ENV_PROJECT_VAR,
26
26
  atomicWriteFileSync,
27
27
  getSessionCacheDir,
28
28
  } from 'src/lib/constants'
29
29
  import { extractStderr } from 'src/lib/errors/operation-error'
30
+ import { getProjectRoot } from 'src/lib/git-utils'
30
31
  import { getInfraKitConfig } from 'src/lib/infra-kit-config'
31
32
  import { defineMcpTool, textContent } from 'src/types'
32
33
 
@@ -43,6 +44,12 @@ interface WriteEnvLoadFileArgs {
43
44
  * marker and lifts any clear suppression so a deliberate load wins.
44
45
  */
45
46
  autoLoaded?: boolean
47
+ /**
48
+ * Auto-load only: re-checked AFTER the Doppler download, immediately before the
49
+ * atomic write. Return false to abort (a clear or a manual load landed during the
50
+ * slow download). Manual loads omit it and always write.
51
+ */
52
+ beforeWrite?: () => boolean
46
53
  }
47
54
 
48
55
  export interface EnvLoadFileResult {
@@ -53,9 +60,10 @@ export interface EnvLoadFileResult {
53
60
  }
54
61
 
55
62
  interface EnvLoadFileLinesArgs {
56
- envContent: string
63
+ pairs: Array<[string, string]>
57
64
  config: string
58
65
  project: string
66
+ projectRoot: string
59
67
  loadedAt: string
60
68
  autoLoaded: boolean
61
69
  }
@@ -84,23 +92,41 @@ const buildAutoLoadMarkerLines = (autoLoaded: boolean): string[] => {
84
92
  * assignment when the file is sourced.
85
93
  */
86
94
  export const buildEnvLoadFileLines = ({
87
- envContent,
95
+ pairs,
88
96
  config,
89
97
  project,
98
+ projectRoot,
90
99
  loadedAt,
91
100
  autoLoaded,
92
101
  }: EnvLoadFileLinesArgs): string[] => {
93
102
  return [
94
103
  'set -a',
95
- envContent,
104
+ ...pairs.map(([key, value]) => {
105
+ return `${key}=${shellSingleQuote(value)}`
106
+ }),
96
107
  `${INFRA_KIT_ENV_CONFIG_VAR}=${shellSingleQuote(config)}`,
97
108
  `${INFRA_KIT_ENV_PROJECT_VAR}=${shellSingleQuote(project)}`,
109
+ `${INFRA_KIT_ENV_PROJECT_ROOT_VAR}=${shellSingleQuote(projectRoot)}`,
98
110
  `${INFRA_KIT_ENV_LOADED_AT_VAR}=${shellSingleQuote(loadedAt)}`,
99
111
  ...buildAutoLoadMarkerLines(autoLoaded),
100
112
  'set +a',
101
113
  ]
102
114
  }
103
115
 
116
+ /**
117
+ * Project root (git top-level) the loaded env belongs to, for the cross-project
118
+ * shell gate. Returns '' when this isn't a git checkout so a non-git infra-kit
119
+ * project still loads — the shell gate then fails open (spawns rather than skips)
120
+ * instead of breaking the whole load on `git rev-parse` throwing.
121
+ */
122
+ const resolveProjectRootSafe = async (): Promise<string> => {
123
+ try {
124
+ return await getProjectRoot()
125
+ } catch {
126
+ return ''
127
+ }
128
+ }
129
+
104
130
  /**
105
131
  * Download Doppler secrets for a resolved config and atomically write env-load.sh
106
132
  * to the session cache dir. Does NOT print to stdout — shared by the CLI/MCP
@@ -110,27 +136,31 @@ export const buildEnvLoadFileLines = ({
110
136
  export const writeEnvLoadFile = async ({
111
137
  config,
112
138
  autoLoaded = false,
113
- }: WriteEnvLoadFileArgs): Promise<EnvLoadFileResult> => {
139
+ beforeWrite,
140
+ }: WriteEnvLoadFileArgs): Promise<EnvLoadFileResult | null> => {
114
141
  await validateDopplerCliAndAuth()
115
142
 
116
143
  const project = await getDopplerProject()
144
+ const projectRoot = await resolveProjectRootSafe()
117
145
 
118
- const envContent = await downloadDopplerSecrets(project, config)
119
-
120
- assertValidEnvContent(envContent)
146
+ const pairs = await downloadDopplerSecrets(project, config)
121
147
 
122
148
  const loadedAt = new Date().toISOString()
123
- const envFileLines = buildEnvLoadFileLines({ envContent, config, project, loadedAt, autoLoaded })
149
+ const envFileLines = buildEnvLoadFileLines({ pairs, config, project, projectRoot, loadedAt, autoLoaded })
124
150
 
125
151
  const cacheDir = getSessionCacheDir()
126
152
  const envFilePath = path.resolve(cacheDir, ENV_LOAD_FILE)
127
153
 
154
+ // Re-check suppression immediately before the atomic write: a clear or a manual
155
+ // load may have landed during the slow Doppler download (auto-load path only).
156
+ if (beforeWrite && !beforeWrite()) return null
157
+
128
158
  fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
129
159
  atomicWriteFileSync(envFilePath, `${envFileLines.join('\n')}\n`, 0o600)
130
160
 
131
161
  return {
132
162
  filePath: envFilePath,
133
- variableCount: countEnvVarLines(envContent),
163
+ variableCount: pairs.length,
134
164
  project,
135
165
  config,
136
166
  }
@@ -175,6 +205,10 @@ export const envLoad = async (args: EnvLoadArgs) => {
175
205
  // A manual load is authoritative: autoLoaded=false drops the auto marker.
176
206
  const result = await writeEnvLoadFile({ config: selectedConfig, autoLoaded: false })
177
207
 
208
+ // A manual load passes no beforeWrite, so it always writes — this guards the
209
+ // invariant (and narrows the nullable result) rather than handling a real path.
210
+ if (!result) throw new Error('env-load: write was unexpectedly aborted')
211
+
178
212
  // REQUIRED
179
213
  process.stdout.write(`${result.filePath}\n`)
180
214
 
@@ -209,7 +243,7 @@ export const DOPPLER_MAX_OUTPUT_BYTES = 1024 * 1024
209
243
  */
210
244
  const DOPPLER_DOWNLOAD_TIMEOUT_MS = 30_000
211
245
 
212
- const downloadDopplerSecrets = async (project: string, config: string): Promise<string> => {
246
+ const downloadDopplerSecrets = async (project: string, config: string): Promise<Array<[string, string]>> => {
213
247
  const prevQuiet = $.quiet
214
248
 
215
249
  $.quiet = true
@@ -217,16 +251,17 @@ const downloadDopplerSecrets = async (project: string, config: string): Promise<
217
251
  let result
218
252
 
219
253
  try {
220
- result = await $`doppler secrets download --no-file --format env --project ${project} --config ${config}`.timeout(
221
- DOPPLER_DOWNLOAD_TIMEOUT_MS,
222
- )
254
+ result =
255
+ await $`doppler secrets download --no-file --format json --project ${project} --config ${config}`.timeout(
256
+ DOPPLER_DOWNLOAD_TIMEOUT_MS,
257
+ )
223
258
  } catch (error: unknown) {
224
259
  throw await translateDopplerDownloadError(error, project, config)
225
260
  }
226
261
 
227
262
  assertDopplerOutputSize(result.stdout)
228
263
 
229
- return result.stdout.trim()
264
+ return parseDopplerSecretsJson(result.stdout)
230
265
  } finally {
231
266
  $.quiet = prevQuiet
232
267
  }
@@ -262,42 +297,59 @@ export const assertDopplerOutputSize = (stdout: string): void => {
262
297
  }
263
298
  }
264
299
 
265
- const countEnvVarLines = (content: string): number => {
266
- return content.split('\n').filter((line) => {
267
- return ENV_VAR_LINE_PATTERN.test(line)
268
- }).length
269
- }
270
-
271
- const SHELL_DIRECTIVE_LINES = new Set(['set -a', 'set +a'])
272
-
273
300
  export const shellSingleQuote = (value: string): string => {
274
301
  const escaped = value.replaceAll("'", "'\\''")
275
302
 
276
303
  return `'${escaped}'`
277
304
  }
278
305
 
306
+ /** A valid POSIX env-var name: a letter or underscore, then word characters. */
307
+ const ENV_VAR_NAME_PATTERN = /^[A-Z_]\w*$/i
308
+
279
309
  /**
280
- * Guard against Doppler returning non-env output (auth warnings on stdout,
281
- * partial downloads, HTML error pages, etc.). Every non-blank, non-directive
282
- * line must match KEY=VALUE skipping directives keeps future format tweaks
283
- * cheap without loosening the check.
310
+ * Parse the `doppler secrets download --format json` output (a flat
311
+ * `{"KEY":"value"}` map) into ordered `[key, value]` pairs, validating the shape
312
+ * and every name/value. Values are returned RAW callers must single-quote them
313
+ * before emitting shell (an unquoted `a$(cmd)b` would execute on `source`).
314
+ *
315
+ * @example
316
+ * parseDopplerSecretsJson('{"FOO":"bar","BAZ":"a$(x)"}')
317
+ * // => [['FOO', 'bar'], ['BAZ', 'a$(x)']]
284
318
  */
285
- export const assertValidEnvContent = (content: string): void => {
286
- if (content.trim().length === 0) {
287
- throw new Error('doppler returned empty output for env-load')
319
+ export const parseDopplerSecretsJson = (stdout: string): Array<[string, string]> => {
320
+ let parsed: unknown
321
+
322
+ try {
323
+ parsed = JSON.parse(stdout)
324
+ } catch {
325
+ throw new Error(`doppler returned non-JSON output for env-load (got: ${JSON.stringify(stdout.slice(0, 80))})`)
288
326
  }
289
327
 
290
- for (const line of content.split('\n')) {
291
- const trimmed = line.trim()
328
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
329
+ throw new Error('doppler returned unexpected JSON shape for env-load (expected an object of KEY: value pairs)')
330
+ }
292
331
 
293
- if (trimmed.length === 0 || SHELL_DIRECTIVE_LINES.has(trimmed)) continue
332
+ const pairs: Array<[string, string]> = []
294
333
 
295
- if (!ENV_VAR_LINE_PATTERN.test(trimmed)) {
334
+ for (const [key, value] of Object.entries(parsed)) {
335
+ if (!ENV_VAR_NAME_PATTERN.test(key)) {
296
336
  throw new Error(
297
- `doppler returned unexpected output for env-load (expected KEY=value lines, got: ${JSON.stringify(trimmed.slice(0, 80))})`,
337
+ `doppler returned an invalid env var name for env-load (got: ${JSON.stringify(key.slice(0, 80))})`,
298
338
  )
299
339
  }
340
+
341
+ if (typeof value !== 'string') {
342
+ throw new TypeError(`doppler returned a non-string value for "${key}" (got ${typeof value})`)
343
+ }
344
+
345
+ pairs.push([key, value])
300
346
  }
347
+
348
+ if (pairs.length === 0) {
349
+ throw new Error('doppler returned empty output for env-load')
350
+ }
351
+
352
+ return pairs
301
353
  }
302
354
 
303
355
  // MCP Tool Registration
@@ -10,14 +10,14 @@ describe('buildShellBody — env auto-load shell-startup block', () => {
10
10
  expect(body).toContain('_infra_kit_startup_autoload\n')
11
11
  })
12
12
 
13
- it('gates on a session and skips subshells that already inherited an env', () => {
13
+ it('gates on a session and skips the spawn only when the env is for THIS project root', () => {
14
14
  expect(body).toContain('[[ -z "$INFRA_KIT_SESSION" ]] && return')
15
- expect(body).toContain('[[ -n "$INFRA_KIT_ENV_CONFIG" ]] && return')
15
+ expect(body).toContain('[[ -n "$INFRA_KIT_ENV_CONFIG" && "$dir" == "$INFRA_KIT_ENV_PROJECT_ROOT" ]] && return')
16
16
  })
17
17
 
18
- it('walks up for infra-kit.json (project/worktree gate) before spawning node', () => {
18
+ it('walks up for infra-kit.json with a fixed-point break (no infinite loop on empty $PWD)', () => {
19
19
  expect(body).toContain('[[ -f "$dir/infra-kit.json" ]]')
20
- expect(body).toContain('while [[ "$dir" != / ]]')
20
+ expect(body).toContain('while [[ -n "$dir" && "$dir" != "$prev" ]]')
21
21
  })
22
22
 
23
23
  it('spawns env-autoload backgrounded and silenced (precmd does the sourcing)', () => {
@@ -299,23 +299,19 @@ export const buildShellBody = (): string => {
299
299
  ' local dir="$cache_root/$INFRA_KIT_SESSION"',
300
300
  ' local load_file="$dir/env-load.sh"',
301
301
  ' local clear_file="$dir/env-clear.sh"',
302
- ' local mtime',
303
- ' if [[ -f "$load_file" ]]; then',
304
- ' mtime=$(zstat +mtime -- "$load_file" 2>/dev/null || echo 0)',
305
- ' if (( mtime > _INFRA_KIT_LAST_LOAD_MTIME && mtime >= _INFRA_KIT_SHELL_STARTED )); then',
306
- ' source "$load_file"',
307
- ' _INFRA_KIT_LAST_LOAD_MTIME=$mtime',
302
+ ' local load_mtime=0 clear_mtime=0',
303
+ ' [[ -f "$load_file" ]] && load_mtime=$(zstat +mtime -- "$load_file" 2>/dev/null || echo 0)',
304
+ ' [[ -f "$clear_file" ]] && clear_mtime=$(zstat +mtime -- "$clear_file" 2>/dev/null || echo 0)',
305
+ ' if (( load_mtime > _INFRA_KIT_LAST_LOAD_MTIME && load_mtime >= _INFRA_KIT_SHELL_STARTED && load_mtime >= clear_mtime )); then',
306
+ ' source "$load_file"',
307
+ ' _INFRA_KIT_LAST_LOAD_MTIME=$load_mtime',
308
308
  // eslint-disable-next-line no-template-curly-in-string
309
- ' print -u2 "infra-kit: auto-loaded vars for ${INFRA_KIT_ENV_CONFIG:-?}"',
310
- ' fi',
309
+ ' print -u2 "infra-kit: auto-loaded vars for ${INFRA_KIT_ENV_CONFIG:-?}"',
311
310
  ' fi',
312
- ' if [[ -f "$clear_file" ]]; then',
313
- ' mtime=$(zstat +mtime -- "$clear_file" 2>/dev/null || echo 0)',
314
- ' if (( mtime > _INFRA_KIT_LAST_CLEAR_MTIME && mtime >= _INFRA_KIT_SHELL_STARTED )); then',
315
- ' source "$clear_file"',
316
- ' _INFRA_KIT_LAST_CLEAR_MTIME=$mtime',
317
- ' print -u2 "infra-kit: auto-cleared env"',
318
- ' fi',
311
+ ' if (( clear_mtime > _INFRA_KIT_LAST_CLEAR_MTIME && clear_mtime >= _INFRA_KIT_SHELL_STARTED && clear_mtime > load_mtime )); then',
312
+ ' source "$clear_file"',
313
+ ' _INFRA_KIT_LAST_CLEAR_MTIME=$clear_mtime',
314
+ ' print -u2 "infra-kit: auto-cleared env"',
319
315
  ' fi',
320
316
  '}',
321
317
  'autoload -Uz add-zsh-hook',
@@ -325,21 +321,24 @@ export const buildShellBody = (): string => {
325
321
  // One-shot env auto-load when a NEW shell opens inside an infra-kit project
326
322
  // or worktree (config: envAutoLoad.trigger "shell-startup"). Cheap pure-zsh
327
323
  // project gate (walk up for infra-kit.json) avoids spawning node in unrelated
328
- // shells; the INFRA_KIT_ENV_CONFIG guard skips subshells that already inherited
329
- // a loaded env. The spawn is DETACHED+backgrounded ( ... & ) so it never blocks
330
- // the prompt; it only WRITES env-load.sh and the precmd hook above is the sole
331
- // sourcer (picked up on a subsequent prompt). Trade-off: the gate can't read the
332
- // merged JSON config, so a fresh shell in a project WITHOUT envAutoLoad still
333
- // spawns one background process that resolves config, finds nothing, and exits.
324
+ // shells; the spawn is skipped only when the already-loaded env belongs to THIS
325
+ // project root (INFRA_KIT_ENV_PROJECT_ROOT), so cd'ing into a DIFFERENT project
326
+ // re-loads instead of silently keeping the previous project's secrets. The spawn
327
+ // is DETACHED+backgrounded ( ... & ) so it never blocks the prompt; it only
328
+ // WRITES env-load.sh and the precmd hook above is the sole sourcer (picked up on
329
+ // a subsequent prompt). Trade-off: the gate can't read the merged JSON config, so
330
+ // a fresh shell in a project WITHOUT envAutoLoad still spawns one background
331
+ // process that resolves config, finds nothing, and exits.
334
332
  '_infra_kit_startup_autoload() {',
335
333
  ' [[ -z "$INFRA_KIT_SESSION" ]] && return',
336
- ' [[ -n "$INFRA_KIT_ENV_CONFIG" ]] && return',
337
- ' local dir="$PWD"',
338
- ' while [[ "$dir" != / ]]; do',
334
+ ' local dir="$PWD" prev=""',
335
+ ' while [[ -n "$dir" && "$dir" != "$prev" ]]; do',
339
336
  ' if [[ -f "$dir/infra-kit.json" ]]; then',
337
+ ' [[ -n "$INFRA_KIT_ENV_CONFIG" && "$dir" == "$INFRA_KIT_ENV_PROJECT_ROOT" ]] && return',
340
338
  ` ( ${runCmd} env-autoload & ) >/dev/null 2>&1`,
341
339
  ' return',
342
340
  ' fi',
341
+ ' prev="$dir"',
343
342
  // eslint-disable-next-line no-template-curly-in-string
344
343
  ' dir="${dir:h}"',
345
344
  ' done',
@@ -16,6 +16,7 @@ import { IDE_MODES, addIdeWorktreeFolders } from 'src/integrations/ide'
16
16
  import type { IdeMode } from 'src/integrations/ide'
17
17
  import { commandEcho } from 'src/lib/command-echo'
18
18
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
19
+ import { isPromptCancellation } from 'src/lib/errors/is-prompt-cancellation'
19
20
  import { OperationError } from 'src/lib/errors/operation-error'
20
21
  import { assertManagementContext } from 'src/lib/git-guard'
21
22
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
@@ -225,6 +226,11 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
225
226
  structuredContent,
226
227
  }
227
228
  } catch (error) {
229
+ // A cancelled prompt (Ctrl-C / Esc) is a user back-out, not a failure: let it
230
+ // reach the top-level boundary untouched so it exits cleanly, instead of being
231
+ // logged as an error with a misleading "branches already exist" remediation.
232
+ if (isPromptCancellation(error)) throw error
233
+
228
234
  logger.error({ error }, '❌ Error managing worktrees')
229
235
  throw new OperationError(error, {
230
236
  operation: 'create worktrees',
@@ -7,6 +7,7 @@ import { getReleasePRsWithInfo } from 'src/integrations/gh'
7
7
  import { removeIdeWorktreeFolders } from 'src/integrations/ide'
8
8
  import { commandEcho } from 'src/lib/command-echo'
9
9
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
10
+ import { isPromptCancellation } from 'src/lib/errors/is-prompt-cancellation'
10
11
  import { OperationError } from 'src/lib/errors/operation-error'
11
12
  import { assertManagementContext } from 'src/lib/git-guard'
12
13
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
@@ -143,6 +144,11 @@ export const worktreesRemove = async (options: WorktreeManagementArgs) => {
143
144
  structuredContent,
144
145
  }
145
146
  } catch (error) {
147
+ // A cancelled prompt (Ctrl-C / Esc) is a user back-out, not a failure: let it
148
+ // reach the top-level boundary untouched so it exits cleanly, instead of being
149
+ // logged as an error with a misleading remediation.
150
+ if (isPromptCancellation(error)) throw error
151
+
146
152
  logger.error({ error }, '❌ Error managing worktrees')
147
153
  throw new OperationError(error, {
148
154
  operation: 'remove worktrees',
@@ -8,6 +8,7 @@ import { getReleasePRs } from 'src/integrations/gh'
8
8
  import { removeIdeWorktreeFolders } from 'src/integrations/ide'
9
9
  import { commandEcho } from 'src/lib/command-echo'
10
10
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
+ import { isPromptCancellation } from 'src/lib/errors/is-prompt-cancellation'
11
12
  import { OperationError } from 'src/lib/errors/operation-error'
12
13
  import { assertManagementContext } from 'src/lib/git-guard'
13
14
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
@@ -96,6 +97,11 @@ export const worktreesSync = async (options: WorktreeSyncArgs) => {
96
97
  structuredContent,
97
98
  }
98
99
  } catch (error) {
100
+ // A cancelled prompt (Ctrl-C / Esc) is a user back-out, not a failure: let it
101
+ // reach the top-level boundary untouched so it exits cleanly, instead of being
102
+ // logged as an error with a misleading remediation.
103
+ if (isPromptCancellation(error)) throw error
104
+
99
105
  logger.error({ error }, '❌ Error managing worktrees')
100
106
  throw new OperationError(error, {
101
107
  operation: 'sync worktrees with remote',