@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
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@lkangd/cc-env",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "homepage": "https://github.com/lkangd/cc-env#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/lkangd/cc-env/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/lkangd/cc-env.git"
12
+ },
13
+ "license": "ISC",
14
+ "author": "",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=20.19.2 <21"
21
+ },
22
+ "bin": {
23
+ "cc-env": "dist/cli.js"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.build.json",
27
+ "dev": "tsx src/cli.ts",
28
+ "test": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "@inkjs/ui": "^2.0.0",
32
+ "commander": "^14.0.0",
33
+ "cross-spawn": "^7.0.6",
34
+ "figlet": "^1.11.0",
35
+ "fs-extra": "^11.3.2",
36
+ "gradient-string": "^3.0.0",
37
+ "ink": "^6.3.1",
38
+ "pino": "^10.0.0",
39
+ "react": "^19.1.0",
40
+ "yaml": "^2.8.1",
41
+ "zod": "^4.1.5"
42
+ },
43
+ "devDependencies": {
44
+ "@types/figlet": "^1.7.0",
45
+ "@types/fs-extra": "^11.0.4",
46
+ "@types/gradient-string": "^1.1.6",
47
+ "@types/node": "^20.19.0",
48
+ "@types/react": "^19.1.10",
49
+ "execa": "^9.6.0",
50
+ "react-test-renderer": "^19.2.5",
51
+ "tsx": "^4.20.3",
52
+ "typescript": "^5.9.2",
53
+ "vitest": "^3.2.4"
54
+ }
55
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,337 @@
1
+ import React from 'react'
2
+ import { render } from 'ink'
3
+ import { join } from 'node:path'
4
+ import figlet from 'figlet'
5
+ import gradient from 'gradient-string'
6
+
7
+ import { Command } from 'commander'
8
+
9
+ const h = React.createElement
10
+
11
+ import { createInitCommand } from './commands/init.js'
12
+ import { createPresetCreateCommand } from './commands/preset/create.js'
13
+ import { createDeletePresetCommand } from './commands/preset/delete.js'
14
+ import { PresetDeleteApp } from './ink/preset-delete-app.js'
15
+ import { createShowPresetsCommand } from './commands/preset/show.js'
16
+ import { createRestoreCommand } from './commands/restore.js'
17
+ import { createRunCommand } from './commands/run.js'
18
+ import { findClaudeExecutable } from './core/find-claude.js'
19
+ import { InitApp } from './ink/init-app.js'
20
+ import { renderEnvSummary } from './ink/summary.js'
21
+ import { PresetCreateApp } from './ink/preset-create-app.js'
22
+ import { PresetShowApp } from './ink/preset-show-app.js'
23
+ import { RunPresetSelectApp } from './ink/run-preset-select-app.js'
24
+ import { advanceRestoreFlow, createRestoreFlowState } from './flows/restore-flow.js'
25
+ import { RestoreApp } from './ink/restore-app.js'
26
+ import { CliError } from './core/errors.js'
27
+ import { resolveGlobalRoot } from './core/paths.js'
28
+ import { spawnCommand } from './core/spawn.js'
29
+ import { createClaudeSettingsEnvService } from './services/claude-settings-env-service.js'
30
+ import { createHistoryService } from './services/history-service.js'
31
+ import { createPresetService } from './services/preset-service.js'
32
+ import { createProjectEnvService } from './services/project-env-service.js'
33
+ import { createProjectStateService } from './services/project-state-service.js'
34
+ import { createSettingsEnvService } from './services/settings-env-service.js'
35
+ import { createShellEnvService } from './services/shell-env-service.js'
36
+
37
+ const program = new Command()
38
+
39
+ program.name('cc-env').description('Manage runtime environment variables for Claude Code')
40
+
41
+ const homeDir = process.env.HOME ?? process.cwd()
42
+ const cwd = process.cwd()
43
+ const settingsPath = join(cwd, 'settings.json')
44
+ const globalRoot = resolveGlobalRoot()
45
+
46
+ const claudeSettingsEnvService = createClaudeSettingsEnvService({ homeDir, cwd })
47
+ const settingsEnvService = createSettingsEnvService({ settingsPath })
48
+ const shellEnvService = createShellEnvService({ homeDir })
49
+ const projectEnvService = createProjectEnvService({ cwd })
50
+ const presetService = createPresetService(globalRoot)
51
+ const historyService = createHistoryService(globalRoot)
52
+
53
+ async function runRestoreFlow(context: { records: Awaited<ReturnType<typeof historyService.list>>; yes: boolean }) {
54
+ const state = createRestoreFlowState(context.records)
55
+ const firstRecord = context.records[0]
56
+
57
+ if (!firstRecord) {
58
+ render(h(RestoreApp, { state }))
59
+ return undefined
60
+ }
61
+
62
+ if (context.yes) {
63
+ const selectedRecordState = advanceRestoreFlow(state, {
64
+ type: 'select-record',
65
+ timestamp: firstRecord.timestamp
66
+ })
67
+
68
+ if (firstRecord.action === 'init') {
69
+ const doneState = advanceRestoreFlow(selectedRecordState, { type: 'confirm' })
70
+ if (doneState.step !== 'done') {
71
+ return undefined
72
+ }
73
+
74
+ return {
75
+ confirmed: true,
76
+ timestamp: firstRecord.timestamp
77
+ }
78
+ }
79
+
80
+ const confirmState = advanceRestoreFlow(selectedRecordState, {
81
+ type: 'select-target',
82
+ targetType: firstRecord.targetType,
83
+ ...(firstRecord.targetType === 'preset' ? { targetName: firstRecord.targetName } : {})
84
+ })
85
+
86
+ const doneState = advanceRestoreFlow(confirmState, { type: 'confirm' })
87
+
88
+ if (doneState.step === 'done' && doneState.targetType === 'preset') {
89
+ return {
90
+ confirmed: true,
91
+ timestamp: doneState.selectedTimestamp,
92
+ targetType: doneState.targetType,
93
+ targetName: doneState.targetName
94
+ }
95
+ }
96
+
97
+ if (doneState.step === 'done') {
98
+ return {
99
+ confirmed: true,
100
+ timestamp: doneState.selectedTimestamp,
101
+ targetType: doneState.targetType
102
+ }
103
+ }
104
+
105
+ return undefined
106
+ }
107
+
108
+ let result:
109
+ | {
110
+ confirmed: boolean
111
+ timestamp?: string
112
+ targetType?: 'settings' | 'preset'
113
+ targetName?: string
114
+ }
115
+ | undefined
116
+
117
+ const app = render(
118
+ h(RestoreApp, {
119
+ state,
120
+ onSubmit: value => {
121
+ result = value
122
+ }
123
+ })
124
+ )
125
+
126
+ await app.waitUntilExit()
127
+ return result
128
+ }
129
+
130
+ program.exitOverride().configureOutput({
131
+ writeErr: str => {
132
+ if (!str.startsWith('error:')) {
133
+ process.stderr.write(str)
134
+ }
135
+ }
136
+ })
137
+
138
+ program
139
+ .command('run [args...]')
140
+ .allowUnknownOption(true)
141
+ .description('Run claude with merged environment variables')
142
+ .option('--dry-run', 'Preview the merged env without executing')
143
+ .option('-y, --yes', 'Auto-select the default preset without interactive prompts')
144
+ .action((args, options) => {
145
+ const rawArgs = args ?? []
146
+
147
+ return createRunCommand({
148
+ claudeSettingsEnvService,
149
+ presetService,
150
+ projectEnvService,
151
+ projectStateService: createProjectStateService(globalRoot),
152
+ findClaude: findClaudeExecutable,
153
+ renderPresetSelect: async ({ presets, defaultIndex }) => {
154
+ let result: (typeof presets)[number] | undefined
155
+ const app = render(
156
+ h(RunPresetSelectApp, {
157
+ presets,
158
+ defaultIndex,
159
+ onSubmit: preset => {
160
+ result = preset
161
+ }
162
+ })
163
+ )
164
+ await app.waitUntilExit()
165
+ return result
166
+ },
167
+ spawnCommand
168
+ })({
169
+ args: rawArgs,
170
+ dryRun: options.dryRun ?? false,
171
+ yes: options.yes ?? false,
172
+ cwd
173
+ })
174
+ })
175
+
176
+ program
177
+ .command('init')
178
+ .description('Initialize cc-env for the current project')
179
+ .option('-y, --yes', 'Accept all defaults without interactive prompts')
180
+ .action(options =>
181
+ createInitCommand({
182
+ claudeSettingsEnvService,
183
+ shellEnvService,
184
+ historyService,
185
+ renderEnvSummary,
186
+ renderFlow: async context => {
187
+ if (context.yes) {
188
+ return {
189
+ selectedKeys: context.requiredKeys,
190
+ confirmed: true
191
+ }
192
+ }
193
+
194
+ let result:
195
+ | {
196
+ selectedKeys: string[]
197
+ confirmed: boolean
198
+ }
199
+ | undefined
200
+
201
+ const app = render(
202
+ h(InitApp, {
203
+ ...context,
204
+ onSubmit: value => {
205
+ result = value
206
+ }
207
+ })
208
+ )
209
+
210
+ await app.waitUntilExit()
211
+ return result
212
+ }
213
+ })({
214
+ yes: options.yes
215
+ })
216
+ )
217
+
218
+ program
219
+ .command('restore')
220
+ .description('Restore environment variables from a previous snapshot')
221
+ .option('-y, --yes', 'Accept all defaults without interactive prompts')
222
+ .action(options =>
223
+ createRestoreCommand({
224
+ historyService,
225
+ claudeSettingsEnvService,
226
+ shellEnvService,
227
+ settingsEnvService,
228
+ presetService,
229
+ renderEnvSummary: renderEnvSummary,
230
+ renderFlow: context => runRestoreFlow(context)
231
+ })({
232
+ yes: options.yes
233
+ })
234
+ )
235
+
236
+ const presetCommand = program.command('preset').description('Manage environment presets')
237
+ presetCommand
238
+ .command('show')
239
+ .description('List and view all presets')
240
+ .action(
241
+ createShowPresetsCommand({
242
+ presetService,
243
+ projectEnvService,
244
+ renderShow: async presets => {
245
+ const app = render(h(PresetShowApp, { presets }))
246
+ await app.waitUntilExit()
247
+ }
248
+ })
249
+ )
250
+ presetCommand
251
+ .command('delete')
252
+ .description('Delete a saved preset')
253
+ .action(
254
+ createDeletePresetCommand({
255
+ presetService,
256
+ projectEnvService,
257
+ renderDelete: async presets => {
258
+ let result: (typeof presets)[number] | undefined
259
+ const app = render(
260
+ h(PresetDeleteApp, {
261
+ presets,
262
+ onSubmit: preset => {
263
+ result = preset
264
+ }
265
+ })
266
+ )
267
+ await app.waitUntilExit()
268
+ return result
269
+ }
270
+ })
271
+ )
272
+ presetCommand
273
+ .command('create')
274
+ .description('Create a new environment preset')
275
+ .action(() =>
276
+ createPresetCreateCommand({
277
+ presetService,
278
+ projectEnvService,
279
+ renderFlow: async () => {
280
+ let result: React.ComponentProps<typeof PresetCreateApp>['onSubmit'] extends (result: infer TResult) => unknown
281
+ ? TResult | undefined
282
+ : undefined
283
+ const app = render(
284
+ h(PresetCreateApp, {
285
+ onSubmit: value => {
286
+ result = value
287
+ },
288
+ readFile: async filePath => {
289
+ const { readEnvFile } = await import('./commands/preset/create.js')
290
+ return readEnvFile(filePath)
291
+ },
292
+ globalPresetPath: name => presetService.getPath(name),
293
+ projectEnvPath: join(cwd, '.cc-env', 'env.json')
294
+ })
295
+ )
296
+
297
+ await app.waitUntilExit()
298
+ return result
299
+ }
300
+ })({ cwd })
301
+ )
302
+
303
+ function printBanner() {
304
+ const banner = figlet.textSync('CC ENV', { font: 'ANSI Shadow' })
305
+ const line = '─'.repeat(48)
306
+ const styled = gradient(['#00d2ff', '#7b2ff7', '#ff0080'])(banner)
307
+ process.stderr.write(`\n${styled}\x1b[2m\n${line}\x1b[0m\n\n`)
308
+ }
309
+
310
+ program.hook('preAction', () => {
311
+ printBanner()
312
+ })
313
+
314
+ program.parseAsync(process.argv).catch((error: unknown) => {
315
+ if (error instanceof CliError) {
316
+ process.stderr.write(`\n Error: ${error.message}\n\n`)
317
+ process.exitCode = error.exitCode
318
+ return
319
+ }
320
+
321
+ if (error && typeof error === 'object' && 'code' in error) {
322
+ const { code, message } = error as { code?: string; message?: string }
323
+
324
+ if (code === 'commander.helpDisplayed') {
325
+ process.exitCode = 0
326
+ return
327
+ }
328
+
329
+ const hint = ` Run "cc-env --help" to see available commands and options.\n`
330
+ const formatted = message?.replace(/^error:\s*/i, '') ?? 'Unknown error'
331
+ process.stderr.write(`\n Error: ${formatted}\n\n${hint}\n`)
332
+ process.exitCode = 1
333
+ return
334
+ }
335
+
336
+ throw error
337
+ })
@@ -0,0 +1,139 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ import { CliError } from '../core/errors.js'
5
+ import { envMapSchema, type EnvMap, type InitHistoryRecord, type SourceEntry } from '../core/schema.js'
6
+ import type { ClaudeSettingsSource } from '../services/claude-settings-env-service.js'
7
+ import type { ShellWriteRecord } from '../services/shell-env-service.js'
8
+
9
+ const h = React.createElement
10
+
11
+ const requiredInitKeys = [
12
+ 'ANTHROPIC_AUTH_TOKEN',
13
+ 'ANTHROPIC_BASE_URL',
14
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
15
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
16
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
17
+ 'ANTHROPIC_REASONING_MODEL',
18
+ ] as const
19
+
20
+ type ClaudeSettingsEnvService = {
21
+ read: () => Promise<ClaudeSettingsSource[]>
22
+ write: (sources: Array<{ path: string; env: EnvMap }>) => Promise<void>
23
+ }
24
+
25
+ type ShellEnvService = {
26
+ write: (env: EnvMap) => Promise<ShellWriteRecord[]>
27
+ }
28
+
29
+ type HistoryService = {
30
+ write: (record: InitHistoryRecord) => Promise<unknown>
31
+ }
32
+
33
+ type InitFlowResult = {
34
+ selectedKeys: string[]
35
+ confirmed?: boolean
36
+ }
37
+
38
+ function omitKeys(env: EnvMap, keys: string[]): EnvMap {
39
+ return envMapSchema.parse(
40
+ Object.fromEntries(Object.entries(env).filter(([key]) => !keys.includes(key))),
41
+ )
42
+ }
43
+
44
+ export function createInitCommand({
45
+ claudeSettingsEnvService,
46
+ shellEnvService,
47
+ historyService,
48
+ renderFlow,
49
+ renderEnvSummary,
50
+ }: {
51
+ claudeSettingsEnvService: ClaudeSettingsEnvService
52
+ shellEnvService: ShellEnvService
53
+ historyService: HistoryService
54
+ renderFlow: (context: {
55
+ keys: string[]
56
+ requiredKeys: string[]
57
+ yes: boolean
58
+ sourceFiles: string[]
59
+ }) => Promise<InitFlowResult | void> | InitFlowResult | void
60
+ renderEnvSummary: (props: {
61
+ title: string
62
+ env: EnvMap
63
+ fromFiles?: string[]
64
+ toFiles?: string[]
65
+ footer?: React.ReactNode
66
+ }) => Promise<void>
67
+ }) {
68
+ return async function init({ yes = false }: { yes?: boolean } = {}): Promise<void> {
69
+ const sources = await claudeSettingsEnvService.read()
70
+
71
+ if (sources.every((s) => !s.exists)) {
72
+ throw new CliError('No Claude settings files were found')
73
+ }
74
+
75
+ const effectiveEnv = envMapSchema.parse(
76
+ sources.reduce<Record<string, unknown>>((acc, source) => ({ ...acc, ...source.env }), {}),
77
+ )
78
+ const keys = Object.keys(effectiveEnv).sort()
79
+ const requiredKeys = requiredInitKeys.filter((key) => key in effectiveEnv)
80
+ const sourceFiles = sources.map((s) => s.path)
81
+ const result = await renderFlow({ keys, requiredKeys, yes, sourceFiles })
82
+
83
+ if (!result?.confirmed) {
84
+ return
85
+ }
86
+
87
+ const migratedEnv = envMapSchema.parse(
88
+ Object.fromEntries(
89
+ result.selectedKeys
90
+ .filter((key) => key in effectiveEnv)
91
+ .map((key) => [key, effectiveEnv[key]]),
92
+ ),
93
+ )
94
+
95
+ if (Object.keys(migratedEnv).length === 0) {
96
+ throw new CliError('No selected env values found to migrate')
97
+ }
98
+
99
+ const initSources: SourceEntry[] = sources.map((source) => ({
100
+ file: source.path,
101
+ backup: envMapSchema.parse(
102
+ Object.fromEntries(
103
+ result.selectedKeys
104
+ .filter((key) => key in source.env)
105
+ .map((key) => [key, source.env[key]]),
106
+ ),
107
+ ),
108
+ }))
109
+
110
+ const timestamp = new Date().toISOString()
111
+ const shellWrites = await shellEnvService.write(migratedEnv)
112
+
113
+ await historyService.write({
114
+ timestamp,
115
+ action: 'init',
116
+ migratedKeys: result.selectedKeys,
117
+ sources: initSources,
118
+ shellWrites,
119
+ })
120
+
121
+ await claudeSettingsEnvService.write(
122
+ sources.map((source) => ({
123
+ path: source.path,
124
+ env: omitKeys(source.env, result.selectedKeys),
125
+ })),
126
+ )
127
+
128
+ await renderEnvSummary({
129
+ title: 'Migrated',
130
+ env: migratedEnv,
131
+ fromFiles: initSources.map((s) => s.file),
132
+ toFiles: shellWrites.map((sw) => sw.filePath),
133
+ footer: h(Box, { flexDirection: 'column' },
134
+ h(Text, { color: 'green' }, 'Init complete'),
135
+ h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the migrated environment variables to take effect.'),
136
+ ),
137
+ })
138
+ }
139
+ }
@@ -0,0 +1,96 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { extname } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { CliError } from '../../core/errors.js'
7
+ import { ensureGitignoreEntry } from '../../core/gitignore.js'
8
+ import { type EnvMap } from '../../core/schema.js'
9
+ import { toProcessEnvMap } from '../../core/process-env.js'
10
+ import type { PresetCreateAppResult } from '../../ink/preset-create-app.js'
11
+
12
+ type PresetService = {
13
+ write: (preset: {
14
+ name: string
15
+ createdAt: string
16
+ updatedAt: string
17
+ env: EnvMap
18
+ }) => Promise<unknown>
19
+ }
20
+
21
+ type ProjectEnvService = {
22
+ write: (env: EnvMap, meta?: { name?: string; createdAt?: string; updatedAt?: string }) => Promise<unknown>
23
+ }
24
+
25
+ export async function readEnvFile(filePath: string): Promise<{ allKeys: string[]; env: EnvMap }> {
26
+ try {
27
+ const content = await readFile(filePath, 'utf8')
28
+ const extension = extname(filePath).toLowerCase()
29
+
30
+ if (extension !== '.yaml' && extension !== '.yml' && extension !== '.json') {
31
+ throw new CliError(`Unsupported file format: ${extension}`, 2)
32
+ }
33
+
34
+ const parsed = extension === '.yaml' || extension === '.yml'
35
+ ? parseYaml(content)
36
+ : JSON.parse(content)
37
+
38
+ const raw = (parsed ?? {}) as Record<string, unknown>
39
+ const source = extension === '.json'
40
+ && raw
41
+ && typeof raw === 'object'
42
+ && 'env' in raw
43
+ && raw.env
44
+ && typeof raw.env === 'object'
45
+ && !Array.isArray(raw.env)
46
+ ? raw.env as Record<string, unknown>
47
+ : raw
48
+
49
+ const env = toProcessEnvMap(source)
50
+ return {
51
+ allKeys: Object.keys(env),
52
+ env,
53
+ }
54
+ } catch (error) {
55
+ if (error instanceof CliError) throw error
56
+ throw new CliError(`Failed to read env file: ${filePath}`, 2)
57
+ }
58
+ }
59
+
60
+ export function createPresetCreateCommand({
61
+ presetService,
62
+ projectEnvService,
63
+ renderFlow,
64
+ ensureGitignore = (dir, entry) => ensureGitignoreEntry(dir, entry),
65
+ }: {
66
+ presetService: PresetService
67
+ projectEnvService: ProjectEnvService
68
+ renderFlow: () => Promise<PresetCreateAppResult | void>
69
+ ensureGitignore?: (dir: string, entry: string) => Promise<void>
70
+ }) {
71
+ return async function createPreset({ cwd }: { cwd: string }): Promise<void> {
72
+ const result = await renderFlow()
73
+
74
+ if (!result) return
75
+
76
+ const selectedEnv: EnvMap = {}
77
+ for (const key of result.selectedKeys) {
78
+ selectedEnv[key] = result.env[key] ?? ''
79
+ }
80
+
81
+ const timestamp = new Date().toISOString()
82
+
83
+ if (result.destination === 'project') {
84
+ await projectEnvService.write(selectedEnv, { name: result.presetName, createdAt: timestamp, updatedAt: timestamp })
85
+ await ensureGitignore(cwd, '.cc-env')
86
+ return
87
+ }
88
+
89
+ await presetService.write({
90
+ name: result.presetName,
91
+ createdAt: timestamp,
92
+ updatedAt: timestamp,
93
+ env: selectedEnv,
94
+ })
95
+ }
96
+ }
@@ -0,0 +1,62 @@
1
+ import type { EnvMap } from '../../core/schema.js'
2
+
3
+ export type PresetSource = 'global' | 'project'
4
+
5
+ type PresetService = {
6
+ listNames: () => Promise<string[]>
7
+ read: (name: string) => Promise<{ env: EnvMap }>
8
+ remove: (name: string) => Promise<void>
9
+ }
10
+
11
+ type ProjectEnvService = {
12
+ readWithMeta: () => Promise<{ env: EnvMap; name?: string | undefined }>
13
+ write: (env: EnvMap) => Promise<EnvMap>
14
+ }
15
+
16
+ export type PresetDeleteItem = {
17
+ name: string
18
+ env: EnvMap
19
+ source: PresetSource
20
+ }
21
+
22
+ export function createDeletePresetCommand({
23
+ presetService,
24
+ projectEnvService,
25
+ renderDelete,
26
+ }: {
27
+ presetService: PresetService
28
+ projectEnvService: ProjectEnvService
29
+ renderDelete: (presets: Array<PresetDeleteItem>) => Promise<PresetDeleteItem | undefined>
30
+ }) {
31
+ return async function deletePreset(): Promise<void> {
32
+ const names = await presetService.listNames()
33
+ const globalPresets = await Promise.all(
34
+ names.map((name) =>
35
+ presetService.read(name).then((p) => ({ name, env: p.env, source: 'global' as const })),
36
+ ),
37
+ )
38
+
39
+ const { env: projectEnv, name: projectName } = await projectEnvService.readWithMeta()
40
+ const projectPreset =
41
+ Object.keys(projectEnv).length > 0
42
+ ? [{ name: projectName ?? 'project', env: projectEnv, source: 'project' as const }]
43
+ : []
44
+
45
+ const presets = [...projectPreset, ...globalPresets]
46
+ if (presets.length === 0) {
47
+ console.log('No presets found.')
48
+ return
49
+ }
50
+
51
+ const selected = await renderDelete(presets)
52
+ if (!selected) return
53
+
54
+ if (selected.source === 'project') {
55
+ await projectEnvService.write({})
56
+ } else {
57
+ await presetService.remove(selected.name)
58
+ }
59
+
60
+ console.log(`Deleted preset: ${selected.name}`)
61
+ }
62
+ }