@lkangd/cc-env 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude/settings.json +6 -0
  2. package/.claude/settings.local.json +3 -0
  3. package/.nvmrc +1 -0
  4. package/dist/cli.js +266 -0
  5. package/dist/commands/debug.js +17 -0
  6. package/dist/commands/init.js +64 -0
  7. package/dist/commands/preset/create.js +61 -0
  8. package/dist/commands/preset/delete.js +25 -0
  9. package/dist/commands/preset/edit.js +15 -0
  10. package/dist/commands/preset/list.js +16 -0
  11. package/dist/commands/preset/show.js +16 -0
  12. package/dist/commands/restore.js +65 -0
  13. package/dist/commands/run.js +80 -0
  14. package/dist/core/errors.js +11 -0
  15. package/dist/core/find-claude.js +64 -0
  16. package/dist/core/format.js +23 -0
  17. package/dist/core/fs.js +12 -0
  18. package/dist/core/gitignore.js +23 -0
  19. package/dist/core/lock.js +25 -0
  20. package/dist/core/logger.js +8 -0
  21. package/dist/core/mask.js +13 -0
  22. package/dist/core/paths.js +32 -0
  23. package/dist/core/process-env.js +4 -0
  24. package/dist/core/schema.js +38 -0
  25. package/dist/core/spawn.js +26 -0
  26. package/dist/flows/init-flow.js +35 -0
  27. package/dist/flows/preset-create-flow.js +80 -0
  28. package/dist/flows/restore-flow.js +75 -0
  29. package/dist/ink/init-app.js +54 -0
  30. package/dist/ink/preset-create-app.js +271 -0
  31. package/dist/ink/preset-delete-app.js +47 -0
  32. package/dist/ink/preset-list-app.js +27 -0
  33. package/dist/ink/preset-show-app.js +27 -0
  34. package/dist/ink/restore-app.js +102 -0
  35. package/dist/ink/run-preset-select-app.js +31 -0
  36. package/dist/ink/summary.js +28 -0
  37. package/dist/services/claude-settings-env-service.js +55 -0
  38. package/dist/services/config-service.js +26 -0
  39. package/dist/services/history-service.js +39 -0
  40. package/dist/services/preset-service.js +61 -0
  41. package/dist/services/project-env-service.js +90 -0
  42. package/dist/services/project-state-service.js +26 -0
  43. package/dist/services/runtime-env-service.js +13 -0
  44. package/dist/services/settings-env-service.js +36 -0
  45. package/dist/services/shell-env-service.js +77 -0
  46. package/docs/product-specs/index.draft.md +106 -0
  47. package/docs/product-specs/index.md +911 -0
  48. package/docs/product-specs/optional.md +42 -0
  49. package/docs/references/claude-code-env.md +224 -0
  50. package/docs/superpowers/plans/2026-04-24-cc-env-init-shell-migration.md +1331 -0
  51. package/docs/superpowers/plans/2026-04-24-cc-env.md +1666 -0
  52. package/docs/superpowers/plans/2026-04-26-preset-create-interactive-refactor.md +1432 -0
  53. package/docs/superpowers/specs/2026-04-24-cc-env-design.md +438 -0
  54. package/docs/superpowers/specs/2026-04-24-cc-env-init-shell-migration-design.md +181 -0
  55. package/docs/superpowers/specs/2026-04-26-preset-create-interactive-refactor-design.md +78 -0
  56. package/package.json +55 -0
  57. package/src/cli.ts +337 -0
  58. package/src/commands/init.ts +139 -0
  59. package/src/commands/preset/create.ts +96 -0
  60. package/src/commands/preset/delete.ts +62 -0
  61. package/src/commands/preset/show.ts +51 -0
  62. package/src/commands/restore.ts +150 -0
  63. package/src/commands/run.ts +158 -0
  64. package/src/core/errors.ts +13 -0
  65. package/src/core/find-claude.ts +70 -0
  66. package/src/core/format.ts +29 -0
  67. package/src/core/fs.ts +18 -0
  68. package/src/core/gitignore.ts +26 -0
  69. package/src/core/logger.ts +11 -0
  70. package/src/core/mask.ts +17 -0
  71. package/src/core/paths.ts +41 -0
  72. package/src/core/process-env.ts +11 -0
  73. package/src/core/schema.ts +55 -0
  74. package/src/core/spawn.ts +36 -0
  75. package/src/flows/init-flow.ts +61 -0
  76. package/src/flows/preset-create-flow.ts +129 -0
  77. package/src/flows/restore-flow.ts +144 -0
  78. package/src/ink/init-app.tsx +110 -0
  79. package/src/ink/preset-create-app.tsx +451 -0
  80. package/src/ink/preset-delete-app.tsx +114 -0
  81. package/src/ink/preset-show-app.tsx +76 -0
  82. package/src/ink/restore-app.tsx +230 -0
  83. package/src/ink/run-preset-select-app.tsx +83 -0
  84. package/src/ink/summary.tsx +91 -0
  85. package/src/services/claude-settings-env-service.ts +72 -0
  86. package/src/services/history-service.ts +48 -0
  87. package/src/services/preset-service.ts +72 -0
  88. package/src/services/project-env-service.ts +128 -0
  89. package/src/services/project-state-service.ts +31 -0
  90. package/src/services/settings-env-service.ts +40 -0
  91. package/src/services/shell-env-service.ts +112 -0
  92. package/src/types.d.ts +19 -0
  93. package/tests/cli/help.test.ts +133 -0
  94. package/tests/cli/init.test.ts +76 -0
  95. package/tests/cli/restore.test.ts +172 -0
  96. package/tests/commands/create.test.ts +263 -0
  97. package/tests/commands/output.test.ts +119 -0
  98. package/tests/commands/run.test.ts +218 -0
  99. package/tests/core/gitignore.test.ts +98 -0
  100. package/tests/core/paths.test.ts +24 -0
  101. package/tests/core/schema-mask.test.ts +182 -0
  102. package/tests/core/spawn.test.ts +47 -0
  103. package/tests/flows/init-flow.test.ts +40 -0
  104. package/tests/flows/preset-create-flow.test.ts +225 -0
  105. package/tests/flows/restore-flow.test.ts +157 -0
  106. package/tests/integration/init-restore.test.ts +406 -0
  107. package/tests/services/claude-shell.test.ts +183 -0
  108. package/tests/services/storage.test.ts +143 -0
  109. package/tsconfig.build.json +9 -0
  110. package/tsconfig.json +22 -0
  111. package/vitest.config.ts +8 -0
@@ -0,0 +1,406 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createInitCommand } from '../../src/commands/init.js'
4
+ import { createRestoreCommand } from '../../src/commands/restore.js'
5
+
6
+ const globalSettingsPath = '/Users/test/.claude/settings.json'
7
+ const globalSettingsLocalPath = '/Users/test/.claude/settings.local.json'
8
+ const projectSettingsPath = '/project/.claude/settings.json'
9
+ const projectSettingsLocalPath = '/project/.claude/settings.local.json'
10
+
11
+ const allPaths = [globalSettingsPath, globalSettingsLocalPath, projectSettingsPath, projectSettingsLocalPath]
12
+
13
+ describe('createInitCommand', () => {
14
+ it('migrates effective env from Claude settings into shell blocks and records per-file backups', async () => {
15
+ const claudeSettingsEnvService = {
16
+ read: vi.fn().mockResolvedValue([
17
+ { path: globalSettingsPath, exists: true, env: { ANTHROPIC_BASE_URL: 'https://settings.example.com' } },
18
+ { path: globalSettingsLocalPath, exists: true, env: { ANTHROPIC_AUTH_TOKEN: 'local-token', ANTHROPIC_BASE_URL: 'https://local.example.com' } },
19
+ { path: projectSettingsPath, exists: false, env: {} },
20
+ { path: projectSettingsLocalPath, exists: false, env: {} },
21
+ ]),
22
+ write: vi.fn().mockResolvedValue(undefined),
23
+ }
24
+ const shellEnvService = {
25
+ write: vi.fn().mockResolvedValue([
26
+ {
27
+ shell: 'zsh',
28
+ filePath: '/Users/test/.zshrc',
29
+ env: {
30
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
31
+ ANTHROPIC_BASE_URL: 'https://local.example.com',
32
+ },
33
+ },
34
+ ]),
35
+ }
36
+ const historyService = {
37
+ write: vi.fn().mockResolvedValue(undefined),
38
+ }
39
+ const renderFlow = vi.fn().mockResolvedValue({
40
+ confirmed: true,
41
+ selectedKeys: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL'],
42
+ })
43
+
44
+ const init = createInitCommand({
45
+ claudeSettingsEnvService,
46
+ shellEnvService,
47
+ historyService,
48
+ renderFlow,
49
+ renderEnvSummary: vi.fn().mockResolvedValue(undefined),
50
+ })
51
+
52
+ await expect(init({ yes: false })).resolves.toBeUndefined()
53
+
54
+ expect(renderFlow).toHaveBeenCalledWith({
55
+ keys: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL'],
56
+ requiredKeys: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL'],
57
+ sourceFiles: allPaths,
58
+ yes: false,
59
+ })
60
+ expect(shellEnvService.write).toHaveBeenCalledWith({
61
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
62
+ ANTHROPIC_BASE_URL: 'https://local.example.com',
63
+ })
64
+ expect(historyService.write).toHaveBeenCalledWith({
65
+ timestamp: expect.any(String),
66
+ action: 'init',
67
+ migratedKeys: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL'],
68
+ sources: [
69
+ {
70
+ file: globalSettingsPath,
71
+ backup: {
72
+ ANTHROPIC_BASE_URL: 'https://settings.example.com',
73
+ },
74
+ },
75
+ {
76
+ file: globalSettingsLocalPath,
77
+ backup: {
78
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
79
+ ANTHROPIC_BASE_URL: 'https://local.example.com',
80
+ },
81
+ },
82
+ {
83
+ file: projectSettingsPath,
84
+ backup: {},
85
+ },
86
+ {
87
+ file: projectSettingsLocalPath,
88
+ backup: {},
89
+ },
90
+ ],
91
+ shellWrites: [
92
+ {
93
+ shell: 'zsh',
94
+ filePath: '/Users/test/.zshrc',
95
+ env: {
96
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
97
+ ANTHROPIC_BASE_URL: 'https://local.example.com',
98
+ },
99
+ },
100
+ ],
101
+ })
102
+ expect(claudeSettingsEnvService.write).toHaveBeenCalledWith([
103
+ { path: globalSettingsPath, env: {} },
104
+ { path: globalSettingsLocalPath, env: {} },
105
+ { path: projectSettingsPath, env: {} },
106
+ { path: projectSettingsLocalPath, env: {} },
107
+ ])
108
+ })
109
+
110
+ it('fails when all Claude settings files are missing', async () => {
111
+ const init = createInitCommand({
112
+ claudeSettingsEnvService: {
113
+ read: vi.fn().mockResolvedValue([
114
+ { path: globalSettingsPath, exists: false, env: {} },
115
+ { path: globalSettingsLocalPath, exists: false, env: {} },
116
+ { path: projectSettingsPath, exists: false, env: {} },
117
+ { path: projectSettingsLocalPath, exists: false, env: {} },
118
+ ]),
119
+ write: vi.fn(),
120
+ },
121
+ shellEnvService: { write: vi.fn() },
122
+ historyService: { write: vi.fn() },
123
+ renderFlow: vi.fn(),
124
+ renderEnvSummary: vi.fn().mockResolvedValue(undefined),
125
+ })
126
+
127
+ await expect(init({ yes: false })).rejects.toMatchObject({
128
+ message: 'No Claude settings files were found',
129
+ exitCode: 1,
130
+ })
131
+ })
132
+ })
133
+
134
+ describe('createRestoreCommand', () => {
135
+ it('restores an init record by removing shell keys and restoring both Claude settings files', async () => {
136
+ const historyService = {
137
+ list: vi.fn().mockResolvedValue([
138
+ {
139
+ timestamp: '2026-04-24T00:00:00.000Z',
140
+ action: 'init',
141
+ migratedKeys: ['ANTHROPIC_AUTH_TOKEN'],
142
+ sources: [
143
+ {
144
+ file: globalSettingsPath,
145
+ backup: {},
146
+ },
147
+ {
148
+ file: globalSettingsLocalPath,
149
+ backup: {
150
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
151
+ },
152
+ },
153
+ ],
154
+ shellWrites: [
155
+ {
156
+ shell: 'zsh',
157
+ filePath: '/Users/test/.zshrc',
158
+ env: {
159
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
160
+ },
161
+ },
162
+ ],
163
+ },
164
+ ]),
165
+ }
166
+ const claudeSettingsEnvService = {
167
+ read: vi.fn().mockResolvedValue([
168
+ { path: globalSettingsPath, exists: true, env: {} },
169
+ { path: globalSettingsLocalPath, exists: true, env: {} },
170
+ { path: projectSettingsPath, exists: false, env: {} },
171
+ { path: projectSettingsLocalPath, exists: false, env: {} },
172
+ ]),
173
+ write: vi.fn().mockResolvedValue(undefined),
174
+ }
175
+ const shellEnvService = {
176
+ removeKeys: vi.fn().mockResolvedValue(undefined),
177
+ }
178
+ const presetService = {
179
+ read: vi.fn(),
180
+ write: vi.fn(),
181
+ }
182
+ const renderFlow = vi.fn().mockResolvedValue({
183
+ confirmed: true,
184
+ timestamp: '2026-04-24T00:00:00.000Z',
185
+ })
186
+
187
+ const restore = createRestoreCommand({
188
+ historyService,
189
+ claudeSettingsEnvService,
190
+ shellEnvService,
191
+ settingsEnvService: {
192
+ read: vi.fn(),
193
+ write: vi.fn(),
194
+ },
195
+ presetService,
196
+ renderEnvSummary: vi.fn().mockResolvedValue(undefined),
197
+ renderFlow,
198
+ })
199
+
200
+ await expect(restore({ yes: false })).resolves.toBeUndefined()
201
+
202
+ expect(shellEnvService.removeKeys).toHaveBeenCalledWith(
203
+ [
204
+ {
205
+ shell: 'zsh',
206
+ filePath: '/Users/test/.zshrc',
207
+ env: {
208
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
209
+ },
210
+ },
211
+ ],
212
+ ['ANTHROPIC_AUTH_TOKEN'],
213
+ )
214
+ expect(claudeSettingsEnvService.write).toHaveBeenCalledWith([
215
+ { path: globalSettingsPath, env: {} },
216
+ { path: globalSettingsLocalPath, env: { ANTHROPIC_AUTH_TOKEN: 'local-token' } },
217
+ { path: projectSettingsPath, env: {} },
218
+ { path: projectSettingsLocalPath, env: {} },
219
+ ])
220
+ expect(presetService.read).not.toHaveBeenCalled()
221
+ expect(presetService.write).not.toHaveBeenCalled()
222
+ })
223
+
224
+ it('restores the selected latest init record including extra migrated keys', async () => {
225
+ const historyService = {
226
+ list: vi.fn().mockResolvedValue([
227
+ {
228
+ timestamp: '2026-04-24T00:00:00.000Z',
229
+ action: 'init',
230
+ migratedKeys: ['ANTHROPIC_AUTH_TOKEN'],
231
+ sources: [
232
+ {
233
+ file: globalSettingsPath,
234
+ backup: {},
235
+ },
236
+ {
237
+ file: globalSettingsLocalPath,
238
+ backup: {
239
+ ANTHROPIC_AUTH_TOKEN: 'old-local-token',
240
+ },
241
+ },
242
+ ],
243
+ shellWrites: [
244
+ {
245
+ shell: 'fish',
246
+ filePath: '/Users/test/.config/fish/config.fish',
247
+ env: {
248
+ ANTHROPIC_AUTH_TOKEN: 'old-local-token',
249
+ },
250
+ },
251
+ ],
252
+ },
253
+ {
254
+ timestamp: '2026-04-25T00:00:00.000Z',
255
+ action: 'init',
256
+ migratedKeys: ['ANTHROPIC_AUTH_TOKEN', 'API_TIMEOUT_MS'],
257
+ sources: [
258
+ {
259
+ file: globalSettingsPath,
260
+ backup: {},
261
+ },
262
+ {
263
+ file: globalSettingsLocalPath,
264
+ backup: {
265
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
266
+ API_TIMEOUT_MS: '3000000',
267
+ },
268
+ },
269
+ ],
270
+ shellWrites: [
271
+ {
272
+ shell: 'fish',
273
+ filePath: '/Users/test/.config/fish/config.fish',
274
+ env: {
275
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
276
+ API_TIMEOUT_MS: '3000000',
277
+ },
278
+ },
279
+ ],
280
+ },
281
+ ]),
282
+ }
283
+ const claudeSettingsEnvService = {
284
+ read: vi.fn().mockResolvedValue([
285
+ { path: globalSettingsPath, exists: true, env: {} },
286
+ { path: globalSettingsLocalPath, exists: true, env: {} },
287
+ { path: projectSettingsPath, exists: false, env: {} },
288
+ { path: projectSettingsLocalPath, exists: false, env: {} },
289
+ ]),
290
+ write: vi.fn().mockResolvedValue(undefined),
291
+ }
292
+ const shellEnvService = {
293
+ removeKeys: vi.fn().mockResolvedValue(undefined),
294
+ }
295
+ const presetService = {
296
+ read: vi.fn(),
297
+ write: vi.fn(),
298
+ }
299
+ const renderFlow = vi.fn().mockResolvedValue({
300
+ confirmed: true,
301
+ timestamp: '2026-04-25T00:00:00.000Z',
302
+ })
303
+
304
+ const restore = createRestoreCommand({
305
+ historyService,
306
+ claudeSettingsEnvService,
307
+ shellEnvService,
308
+ settingsEnvService: {
309
+ read: vi.fn(),
310
+ write: vi.fn(),
311
+ },
312
+ presetService,
313
+ renderEnvSummary: vi.fn().mockResolvedValue(undefined),
314
+ renderFlow,
315
+ })
316
+
317
+ await expect(restore({ yes: false })).resolves.toBeUndefined()
318
+
319
+ expect(shellEnvService.removeKeys).toHaveBeenCalledWith(
320
+ [
321
+ {
322
+ shell: 'fish',
323
+ filePath: '/Users/test/.config/fish/config.fish',
324
+ env: {
325
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
326
+ API_TIMEOUT_MS: '3000000',
327
+ },
328
+ },
329
+ ],
330
+ ['ANTHROPIC_AUTH_TOKEN', 'API_TIMEOUT_MS'],
331
+ )
332
+ expect(claudeSettingsEnvService.write).toHaveBeenCalledWith([
333
+ { path: globalSettingsPath, env: {} },
334
+ { path: globalSettingsLocalPath, env: { ANTHROPIC_AUTH_TOKEN: 'local-token', API_TIMEOUT_MS: '3000000' } },
335
+ { path: projectSettingsPath, env: {} },
336
+ { path: projectSettingsLocalPath, env: {} },
337
+ ])
338
+ })
339
+
340
+ it('restores a non-init history record into a preset', async () => {
341
+ const historyService = {
342
+ list: vi.fn().mockResolvedValue([
343
+ {
344
+ timestamp: '2026-04-25T00:00:00.000Z',
345
+ action: 'restore',
346
+ targetType: 'preset',
347
+ targetName: 'openai',
348
+ backup: {
349
+ OPENAI_API_KEY: 'sk-123',
350
+ },
351
+ },
352
+ ]),
353
+ }
354
+ const settingsEnvService = {
355
+ read: vi.fn(),
356
+ write: vi.fn(),
357
+ }
358
+ const presetService = {
359
+ read: vi.fn().mockResolvedValue({
360
+ name: 'openai',
361
+ createdAt: '2026-04-24T00:00:00.000Z',
362
+ updatedAt: '2026-04-24T00:00:00.000Z',
363
+ env: {
364
+ BASE_URL: 'https://api.openai.com',
365
+ },
366
+ }),
367
+ write: vi.fn().mockResolvedValue(undefined),
368
+ }
369
+ const renderFlow = vi.fn().mockResolvedValue({
370
+ confirmed: true,
371
+ timestamp: '2026-04-25T00:00:00.000Z',
372
+ targetType: 'preset',
373
+ targetName: 'openai',
374
+ })
375
+
376
+ const restore = createRestoreCommand({
377
+ historyService,
378
+ claudeSettingsEnvService: {
379
+ read: vi.fn(),
380
+ write: vi.fn(),
381
+ },
382
+ shellEnvService: {
383
+ removeKeys: vi.fn(),
384
+ },
385
+ settingsEnvService,
386
+ presetService,
387
+ renderEnvSummary: vi.fn().mockResolvedValue(undefined),
388
+ renderFlow,
389
+ })
390
+
391
+ await expect(restore({ yes: false })).resolves.toBeUndefined()
392
+
393
+ expect(presetService.read).toHaveBeenCalledWith('openai')
394
+ expect(presetService.write).toHaveBeenCalledWith({
395
+ name: 'openai',
396
+ createdAt: '2026-04-24T00:00:00.000Z',
397
+ updatedAt: expect.any(String),
398
+ env: {
399
+ BASE_URL: 'https://api.openai.com',
400
+ OPENAI_API_KEY: 'sk-123',
401
+ },
402
+ })
403
+ expect(settingsEnvService.read).not.toHaveBeenCalled()
404
+ expect(settingsEnvService.write).not.toHaveBeenCalled()
405
+ })
406
+ })
@@ -0,0 +1,183 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+
7
+ import { createClaudeSettingsEnvService } from '../../src/services/claude-settings-env-service.js'
8
+ import { createShellEnvService } from '../../src/services/shell-env-service.js'
9
+
10
+ const roots: string[] = []
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
14
+ })
15
+
16
+ describe('Claude settings env service', () => {
17
+ it('reads all four settings files with correct priority order', async () => {
18
+ const homeDir = await mkdtemp(join(tmpdir(), 'cc-env-home-'))
19
+ const cwd = await mkdtemp(join(tmpdir(), 'cc-env-cwd-'))
20
+ roots.push(homeDir, cwd)
21
+
22
+ await mkdir(join(homeDir, '.claude'), { recursive: true })
23
+ await writeFile(
24
+ join(homeDir, '.claude', 'settings.json'),
25
+ '{"env":{"KEY_A":"global-settings","KEY_B":"global-only"}}\n',
26
+ 'utf8',
27
+ )
28
+ await writeFile(
29
+ join(homeDir, '.claude', 'settings.local.json'),
30
+ '{"env":{"KEY_A":"global-local","KEY_C":"global-local-only"}}\n',
31
+ 'utf8',
32
+ )
33
+
34
+ await mkdir(join(cwd, '.claude'), { recursive: true })
35
+ await writeFile(
36
+ join(cwd, '.claude', 'settings.json'),
37
+ '{"env":{"KEY_A":"project-settings","KEY_D":"project-only"}}\n',
38
+ 'utf8',
39
+ )
40
+ await writeFile(
41
+ join(cwd, '.claude', 'settings.local.json'),
42
+ '{"env":{"KEY_A":"project-local","KEY_E":"project-local-only"}}\n',
43
+ 'utf8',
44
+ )
45
+
46
+ const service = createClaudeSettingsEnvService({ homeDir, cwd })
47
+ const sources = await service.read()
48
+
49
+ expect(sources).toHaveLength(4)
50
+ expect(sources[0]).toMatchObject({
51
+ exists: true,
52
+ env: { KEY_A: 'global-settings', KEY_B: 'global-only' },
53
+ })
54
+ expect(sources[1]).toMatchObject({
55
+ exists: true,
56
+ env: { KEY_A: 'global-local', KEY_C: 'global-local-only' },
57
+ })
58
+ expect(sources[2]).toMatchObject({
59
+ exists: true,
60
+ env: { KEY_A: 'project-settings', KEY_D: 'project-only' },
61
+ })
62
+ expect(sources[3]).toMatchObject({
63
+ exists: true,
64
+ env: { KEY_A: 'project-local', KEY_E: 'project-local-only' },
65
+ })
66
+ })
67
+
68
+ it('handles missing files gracefully', async () => {
69
+ const homeDir = await mkdtemp(join(tmpdir(), 'cc-env-home-'))
70
+ const cwd = await mkdtemp(join(tmpdir(), 'cc-env-cwd-'))
71
+ roots.push(homeDir, cwd)
72
+
73
+ await mkdir(join(homeDir, '.claude'), { recursive: true })
74
+ await writeFile(
75
+ join(homeDir, '.claude', 'settings.json'),
76
+ '{"env":{"KEY_A":"global-settings"}}\n',
77
+ 'utf8',
78
+ )
79
+
80
+ const service = createClaudeSettingsEnvService({ homeDir, cwd })
81
+ const sources = await service.read()
82
+
83
+ expect(sources).toHaveLength(4)
84
+ expect(sources[0]!.exists).toBe(true)
85
+ expect(sources[1]!.exists).toBe(false)
86
+ expect(sources[2]!.exists).toBe(false)
87
+ expect(sources[3]!.exists).toBe(false)
88
+ })
89
+
90
+ it('preserves sibling fields when writing updated env values', async () => {
91
+ const homeDir = await mkdtemp(join(tmpdir(), 'cc-env-home-'))
92
+ roots.push(homeDir)
93
+
94
+ await mkdir(join(homeDir, '.claude'), { recursive: true })
95
+ await writeFile(
96
+ join(homeDir, '.claude', 'settings.json'),
97
+ `${JSON.stringify({
98
+ theme: 'dark',
99
+ env: {
100
+ ANTHROPIC_BASE_URL: 'https://settings.example.com',
101
+ },
102
+ }, null, 2)}\n`,
103
+ 'utf8',
104
+ )
105
+ await writeFile(
106
+ join(homeDir, '.claude', 'settings.local.json'),
107
+ `${JSON.stringify({
108
+ permissions: { allow: ['Bash'] },
109
+ env: {
110
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
111
+ },
112
+ }, null, 2)}\n`,
113
+ 'utf8',
114
+ )
115
+
116
+ const service = createClaudeSettingsEnvService({ homeDir })
117
+
118
+ const sources = await service.read()
119
+ await service.write(sources.map((s) => ({ path: s.path, env: {} })))
120
+
121
+ await expect(readFile(join(homeDir, '.claude', 'settings.json'), 'utf8')).resolves.toContain(
122
+ '"theme": "dark"',
123
+ )
124
+ await expect(readFile(join(homeDir, '.claude', 'settings.local.json'), 'utf8')).resolves.toContain(
125
+ '"permissions"',
126
+ )
127
+ })
128
+ })
129
+
130
+ describe('shell env service', () => {
131
+ it('writes and updates only the managed block in all shell files', async () => {
132
+ const homeDir = await mkdtemp(join(tmpdir(), 'cc-env-shell-'))
133
+ roots.push(homeDir)
134
+
135
+ await writeFile(join(homeDir, '.zshrc'), 'export PATH="/bin"\n', 'utf8')
136
+
137
+ const service = createShellEnvService({ homeDir })
138
+
139
+ await service.write({
140
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
141
+ })
142
+
143
+ await expect(readFile(join(homeDir, '.zshrc'), 'utf8')).resolves.toContain(
144
+ '# >>> cc-env >>>',
145
+ )
146
+ await expect(readFile(join(homeDir, '.zshrc'), 'utf8')).resolves.toContain(
147
+ 'export ANTHROPIC_AUTH_TOKEN="local-token"',
148
+ )
149
+ })
150
+
151
+ it('removes only the requested keys from a managed block and leaves user content intact', async () => {
152
+ const homeDir = await mkdtemp(join(tmpdir(), 'cc-env-shell-'))
153
+ roots.push(homeDir)
154
+
155
+ const service = createShellEnvService({ homeDir })
156
+ const shellWrites = await service.write({
157
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
158
+ ANTHROPIC_BASE_URL: 'https://local.example.com',
159
+ })
160
+
161
+ await service.removeKeys(shellWrites, ['ANTHROPIC_AUTH_TOKEN'])
162
+
163
+ await expect(readFile(join(homeDir, '.bashrc'), 'utf8')).resolves.not.toContain(
164
+ 'ANTHROPIC_AUTH_TOKEN',
165
+ )
166
+ await expect(readFile(join(homeDir, '.bashrc'), 'utf8')).resolves.toContain(
167
+ 'ANTHROPIC_BASE_URL',
168
+ )
169
+ })
170
+
171
+ it('collapses blank lines when removing all keys', async () => {
172
+ const homeDir = await mkdtemp(join(tmpdir(), 'cc-env-shell-'))
173
+ roots.push(homeDir)
174
+
175
+ const service = createShellEnvService({ homeDir })
176
+ const shellWrites = await service.write({ API_KEY: 'secret' })
177
+
178
+ await service.removeKeys(shellWrites, ['API_KEY'])
179
+
180
+ const content = await readFile(join(homeDir, '.zshrc'), 'utf8')
181
+ expect(content).not.toMatch(/\n{3,}/)
182
+ })
183
+ })