@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,128 @@
1
+ import { access, mkdir, readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse, stringify } from 'yaml'
5
+
6
+ import { atomicWriteFile } from '../core/fs.js'
7
+ import { CliError } from '../core/errors.js'
8
+ import { envMapSchema, type EnvMap } from '../core/schema.js'
9
+
10
+ export type ProjectEnvMeta = {
11
+ name?: string
12
+ createdAt?: string
13
+ updatedAt?: string
14
+ }
15
+
16
+ function parseEnvelope(value: unknown): {
17
+ env: EnvMap
18
+ name?: string | undefined
19
+ createdAt?: string | undefined
20
+ updatedAt?: string | undefined
21
+ } {
22
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
23
+ const obj = value as Record<string, unknown>
24
+ if (obj.env && typeof obj.env === 'object' && !Array.isArray(obj.env)) {
25
+ return {
26
+ env: envMapSchema.parse(obj.env),
27
+ name: typeof obj.name === 'string' ? obj.name : undefined,
28
+ createdAt: typeof obj.createdAt === 'string' ? obj.createdAt : undefined,
29
+ updatedAt: typeof obj.updatedAt === 'string' ? obj.updatedAt : undefined,
30
+ }
31
+ }
32
+ }
33
+ return { env: envMapSchema.parse(value) }
34
+ }
35
+
36
+ export function createProjectEnvService({ cwd }: { cwd: string }) {
37
+ const envDir = join(cwd, '.cc-env')
38
+ const jsonPath = join(envDir, 'env.json')
39
+ const yamlPath = join(envDir, 'env.yaml')
40
+
41
+ async function exists(filePath: string): Promise<boolean> {
42
+ try {
43
+ await access(filePath)
44
+ return true
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
47
+ return false
48
+ }
49
+
50
+ throw error
51
+ }
52
+ }
53
+
54
+ async function resolveMode(): Promise<'json' | 'yaml' | 'missing'> {
55
+ const [jsonExists, yamlExists] = await Promise.all([exists(jsonPath), exists(yamlPath)])
56
+
57
+ if (jsonExists && yamlExists) {
58
+ throw new CliError('Project env conflict: env.json and env.yaml both exist')
59
+ }
60
+
61
+ if (yamlExists) {
62
+ return 'yaml'
63
+ }
64
+
65
+ if (jsonExists) {
66
+ return 'json'
67
+ }
68
+
69
+ return 'missing'
70
+ }
71
+
72
+ async function readRaw(): Promise<{
73
+ env: EnvMap
74
+ name?: string | undefined
75
+ createdAt?: string | undefined
76
+ updatedAt?: string | undefined
77
+ }> {
78
+ const mode = await resolveMode()
79
+
80
+ if (mode === 'missing') {
81
+ return { env: envMapSchema.parse({}) }
82
+ }
83
+
84
+ const filePath = mode === 'yaml' ? yamlPath : jsonPath
85
+ const content = await readFile(filePath, 'utf8')
86
+ const value = mode === 'yaml' ? parse(content) : JSON.parse(content)
87
+ return parseEnvelope(value ?? {})
88
+ }
89
+
90
+ return {
91
+ async read(): Promise<EnvMap> {
92
+ const { env } = await readRaw()
93
+ return env
94
+ },
95
+
96
+ async readWithMeta(): Promise<{
97
+ env: EnvMap
98
+ name?: string | undefined
99
+ createdAt?: string | undefined
100
+ updatedAt?: string | undefined
101
+ }> {
102
+ return readRaw()
103
+ },
104
+
105
+ async write(env: EnvMap, meta?: ProjectEnvMeta): Promise<EnvMap> {
106
+ const parsedEnv = envMapSchema.parse(env)
107
+ await mkdir(envDir, { recursive: true })
108
+ const mode = await resolveMode()
109
+
110
+ if (meta?.name !== undefined) {
111
+ const envelope: Record<string, unknown> = { name: meta.name, env: parsedEnv }
112
+ if (meta.createdAt !== undefined) envelope.createdAt = meta.createdAt
113
+ if (meta.updatedAt !== undefined) envelope.updatedAt = meta.updatedAt
114
+ const content = `${JSON.stringify(envelope, null, 2)}\n`
115
+ await atomicWriteFile(jsonPath, content)
116
+ return parsedEnv
117
+ }
118
+
119
+ const filePath = mode === 'yaml' ? yamlPath : jsonPath
120
+ const content = mode === 'yaml'
121
+ ? stringify(parsedEnv)
122
+ : `${JSON.stringify(parsedEnv, null, 2)}\n`
123
+
124
+ await atomicWriteFile(filePath, content)
125
+ return parsedEnv
126
+ },
127
+ }
128
+ }
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import { atomicWriteFile } from '../core/fs.js'
5
+
6
+ type PresetRef = { presetName: string; source: 'global' | 'project' }
7
+
8
+ export function createProjectStateService(globalRoot: string) {
9
+ const filePath = join(globalRoot, 'project-state.json')
10
+
11
+ async function readAll(): Promise<Record<string, PresetRef>> {
12
+ try {
13
+ const content = await readFile(filePath, 'utf8')
14
+ return JSON.parse(content) as Record<string, PresetRef>
15
+ } catch {
16
+ return {}
17
+ }
18
+ }
19
+
20
+ return {
21
+ async getLastPreset(cwd: string): Promise<PresetRef | undefined> {
22
+ const state = await readAll()
23
+ return state[cwd]
24
+ },
25
+ async saveLastPreset(cwd: string, ref: PresetRef): Promise<void> {
26
+ const state = await readAll()
27
+ state[cwd] = ref
28
+ await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`)
29
+ },
30
+ }
31
+ }
@@ -0,0 +1,40 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import { atomicWriteFile } from '../core/fs.js'
4
+ import { envMapSchema, type EnvMap } from '../core/schema.js'
5
+
6
+ export function createSettingsEnvService({ settingsPath }: { settingsPath: string }) {
7
+ return {
8
+ async read(): Promise<EnvMap> {
9
+ try {
10
+ const content = await readFile(settingsPath, 'utf8')
11
+ const json = JSON.parse(content) as { env?: unknown }
12
+ return envMapSchema.parse(json.env ?? {})
13
+ } catch (error) {
14
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
15
+ return envMapSchema.parse({})
16
+ }
17
+
18
+ throw error
19
+ }
20
+ },
21
+
22
+ async write(env: EnvMap): Promise<EnvMap> {
23
+ const parsedEnv = envMapSchema.parse(env)
24
+ let json: Record<string, unknown> = {}
25
+
26
+ try {
27
+ const content = await readFile(settingsPath, 'utf8')
28
+ json = JSON.parse(content) as Record<string, unknown>
29
+ } catch (error) {
30
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
31
+ throw error
32
+ }
33
+ }
34
+
35
+ json.env = parsedEnv
36
+ await atomicWriteFile(settingsPath, `${JSON.stringify(json, null, 2)}\n`)
37
+ return parsedEnv
38
+ },
39
+ }
40
+ }
@@ -0,0 +1,112 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import { atomicWriteFile } from '../core/fs.js'
4
+ import { resolveShellConfigPaths } from '../core/paths.js'
5
+ import { envMapSchema, type EnvMap } from '../core/schema.js'
6
+
7
+ const startMarker = '# >>> cc-env >>>'
8
+ const endMarker = '# <<< cc-env <<<'
9
+
10
+ type ShellName = 'zsh' | 'bash' | 'fish'
11
+
12
+ export type ShellWriteRecord = {
13
+ shell: ShellName
14
+ filePath: string
15
+ env: EnvMap
16
+ }
17
+
18
+ function parseManagedEnv(content: string): EnvMap {
19
+ const match = content.match(/# >>> cc-env >>>[\s\S]*?# <<< cc-env <<</)
20
+ if (!match) {
21
+ return envMapSchema.parse({})
22
+ }
23
+
24
+ const lines = match[0]
25
+ .split('\n')
26
+ .slice(1, -1)
27
+ .filter(Boolean)
28
+
29
+ return envMapSchema.parse(
30
+ Object.fromEntries(
31
+ lines.map((line) => {
32
+ if (line.startsWith('set -gx ')) {
33
+ const [, key, value] = line.match(/^set -gx ([A-Z0-9_]+) "(.*)"$/) ?? []
34
+ return [key, value]
35
+ }
36
+
37
+ const [, key, value] = line.match(/^export ([A-Z0-9_]+)="(.*)"$/) ?? []
38
+ return [key, value]
39
+ }),
40
+ ),
41
+ )
42
+ }
43
+
44
+ function renderBlock(shell: ShellName, env: EnvMap): string {
45
+ const lines = Object.entries(env)
46
+ .sort(([left], [right]) => left.localeCompare(right))
47
+ .map(([key, value]) =>
48
+ shell === 'fish' ? `set -gx ${key} "${value}"` : `export ${key}="${value}"`,
49
+ )
50
+
51
+ return [startMarker, ...lines, endMarker, ''].join('\n')
52
+ }
53
+
54
+ function replaceManagedBlock(content: string, block: string): string {
55
+ const pattern = /# >>> cc-env >>>[\s\S]*?# <<< cc-env <<<\n?/
56
+ if (pattern.test(content)) {
57
+ const result = content.replace(pattern, block)
58
+ if (block === '') {
59
+ return result.replace(/\n{3,}/g, '\n\n').replace(/\n{2,}$/, '\n')
60
+ }
61
+ return result
62
+ }
63
+
64
+ return content.length === 0 ? block : `${content.replace(/\n?$/, '\n')}\n${block}`
65
+ }
66
+
67
+ export function createShellEnvService({ homeDir }: { homeDir?: string } = {}) {
68
+ const paths = resolveShellConfigPaths(homeDir)
69
+
70
+ async function readContent(path: string): Promise<string> {
71
+ try {
72
+ return await readFile(path, 'utf8')
73
+ } catch (error) {
74
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
75
+ return ''
76
+ }
77
+
78
+ throw error
79
+ }
80
+ }
81
+
82
+ return {
83
+ async write(env: EnvMap): Promise<ShellWriteRecord[]> {
84
+ return Promise.all(
85
+ (Object.entries(paths) as Array<[ShellName, string]>).map(async ([shell, filePath]) => {
86
+ const content = await readContent(filePath)
87
+ const mergedEnv = envMapSchema.parse({
88
+ ...parseManagedEnv(content),
89
+ ...env,
90
+ })
91
+ await atomicWriteFile(filePath, replaceManagedBlock(content, renderBlock(shell, mergedEnv)))
92
+ return { shell, filePath, env: mergedEnv }
93
+ }),
94
+ )
95
+ },
96
+ async removeKeys(shellWrites: ShellWriteRecord[], keys: string[]): Promise<void> {
97
+ await Promise.all(
98
+ shellWrites.map(async ({ shell, filePath }) => {
99
+ const content = await readContent(filePath)
100
+ const current = parseManagedEnv(content)
101
+ const next = envMapSchema.parse(
102
+ Object.fromEntries(
103
+ Object.entries(current).filter(([key]) => !keys.includes(key)),
104
+ ),
105
+ )
106
+ const block = Object.keys(next).length === 0 ? '' : renderBlock(shell, next)
107
+ await atomicWriteFile(filePath, replaceManagedBlock(content, block))
108
+ }),
109
+ )
110
+ },
111
+ }
112
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ declare module 'cross-spawn' {
2
+ import type { SpawnOptions } from 'node:child_process'
3
+ import type { EventEmitter } from 'node:events'
4
+ import type { Readable, Writable } from 'node:stream'
5
+
6
+ type ChildProcessLike = EventEmitter & {
7
+ stdout?: Readable | null
8
+ stderr?: Readable | null
9
+ stdin?: Writable | null
10
+ once(event: 'error', listener: (error: Error) => void): ChildProcessLike
11
+ once(event: 'close', listener: (code: number | null) => void): ChildProcessLike
12
+ }
13
+
14
+ export default function spawn(
15
+ command: string,
16
+ args?: readonly string[],
17
+ options?: SpawnOptions,
18
+ ): ChildProcessLike
19
+ }
@@ -0,0 +1,133 @@
1
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { execa } from 'execa'
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+
9
+ const repoRoot = fileURLToPath(new URL('../../', import.meta.url))
10
+ const tsxLoader = join(repoRoot, 'node_modules/tsx/dist/loader.mjs')
11
+ const cliEntry = join(repoRoot, 'src/cli.ts')
12
+
13
+ const tempRoots: string[] = []
14
+
15
+ async function createCliFixture() {
16
+ const root = await mkdtemp(join(tmpdir(), 'cc-env-cli-'))
17
+ tempRoots.push(root)
18
+
19
+ const homeDir = join(root, 'home')
20
+ const projectDir = join(root, 'project')
21
+
22
+ await mkdir(join(homeDir, '.cc-env', 'presets'), { recursive: true })
23
+ await mkdir(join(projectDir, '.cc-env'), { recursive: true })
24
+
25
+ await writeFile(
26
+ join(homeDir, '.cc-env', 'config.json'),
27
+ `${JSON.stringify({ defaultPreset: 'openai' }, null, 2)}\n`,
28
+ 'utf8',
29
+ )
30
+ await writeFile(
31
+ join(homeDir, '.cc-env', 'project-state.json'),
32
+ `${JSON.stringify({}, null, 2)}\n`,
33
+ 'utf8',
34
+ )
35
+ await writeFile(
36
+ join(homeDir, '.cc-env', 'presets', 'openai.json'),
37
+ `${JSON.stringify({
38
+ name: 'openai',
39
+ createdAt: '2026-04-24T00:00:00.000Z',
40
+ updatedAt: '2026-04-24T00:00:00.000Z',
41
+ env: {
42
+ PRESET_KEY: 'preset',
43
+ },
44
+ }, null, 2)}\n`,
45
+ 'utf8',
46
+ )
47
+ await writeFile(
48
+ join(projectDir, 'settings.json'),
49
+ `${JSON.stringify({
50
+ env: {
51
+ SETTINGS_KEY: 'settings',
52
+ },
53
+ }, null, 2)}\n`,
54
+ 'utf8',
55
+ )
56
+ await writeFile(
57
+ join(projectDir, '.cc-env', 'env.json'),
58
+ `${JSON.stringify({ PROJECT_KEY: 'project' }, null, 2)}\n`,
59
+ 'utf8',
60
+ )
61
+
62
+ return { homeDir, projectDir }
63
+ }
64
+
65
+ afterEach(async () => {
66
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
67
+ })
68
+
69
+ describe('cc-env CLI help', () => {
70
+ it('shows the top-level commands in --help output', async () => {
71
+ const { stdout } = await execa('node', ['--import', tsxLoader, cliEntry, '--help'], {
72
+ cwd: repoRoot,
73
+ })
74
+
75
+ expect(stdout).toContain('run')
76
+ expect(stdout).toContain('init')
77
+ expect(stdout).toContain('restore')
78
+ expect(stdout).toContain('preset')
79
+ })
80
+
81
+ it('shows the preset subcommands in help output', async () => {
82
+ const { stdout } = await execa('node', ['--import', tsxLoader, cliEntry, 'preset', '--help'], {
83
+ cwd: repoRoot,
84
+ })
85
+
86
+ expect(stdout).toContain('show')
87
+ expect(stdout).toContain('delete')
88
+ })
89
+
90
+ it('uses real HOME and cwd wiring for dry-run env resolution', async () => {
91
+ const { homeDir, projectDir } = await createCliFixture()
92
+ const result = await execa(
93
+ 'node',
94
+ ['--import', tsxLoader, cliEntry, 'run', '--dry-run', '--yes', 'claude'],
95
+ {
96
+ cwd: projectDir,
97
+ env: {
98
+ HOME: homeDir,
99
+ },
100
+ reject: false,
101
+ },
102
+ )
103
+
104
+ expect(result.exitCode).toBe(0)
105
+ expect(result.stdout).toContain('PROJECT_KEY=')
106
+ expect(result.stdout).toContain('Would run: claude')
107
+ })
108
+
109
+ it('prints CliError messages without stack traces at the top level', async () => {
110
+ const { homeDir, projectDir } = await createCliFixture()
111
+
112
+ await mkdir(join(projectDir, '.claude'), { recursive: true })
113
+ await writeFile(
114
+ join(projectDir, '.claude', 'settings.json'),
115
+ `${JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'stale' } }, null, 2)}\n`,
116
+ 'utf8',
117
+ )
118
+
119
+ const result = await execa(
120
+ 'node',
121
+ ['--import', tsxLoader, cliEntry, 'run', '--dry-run', '--yes'],
122
+ {
123
+ cwd: projectDir,
124
+ env: { HOME: homeDir },
125
+ reject: false,
126
+ },
127
+ )
128
+
129
+ expect(result.exitCode).toBe(1)
130
+ expect(result.stderr).not.toContain('CliError:')
131
+ expect(result.stderr).toContain('Error:')
132
+ })
133
+ })
@@ -0,0 +1,76 @@
1
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { execa } from 'execa'
7
+ import { afterEach, describe, expect, it } from 'vitest'
8
+
9
+ const repoRoot = fileURLToPath(new URL('../../', import.meta.url))
10
+ const tsxLoader = join(repoRoot, 'node_modules/tsx/dist/loader.mjs')
11
+ const cliEntry = join(repoRoot, 'src/cli.ts')
12
+
13
+ const tempRoots: string[] = []
14
+
15
+ async function createInitFixture() {
16
+ const root = await mkdtemp(join(tmpdir(), 'cc-env-init-cli-'))
17
+ tempRoots.push(root)
18
+
19
+ const homeDir = join(root, 'home')
20
+ await mkdir(join(homeDir, '.claude'), { recursive: true })
21
+
22
+ await writeFile(
23
+ join(homeDir, '.claude', 'settings.json'),
24
+ `${JSON.stringify({
25
+ env: {
26
+ ANTHROPIC_BASE_URL: 'https://settings.example.com',
27
+ },
28
+ }, null, 2)}\n`,
29
+ 'utf8',
30
+ )
31
+ await writeFile(
32
+ join(homeDir, '.claude', 'settings.local.json'),
33
+ `${JSON.stringify({
34
+ env: {
35
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
36
+ },
37
+ }, null, 2)}\n`,
38
+ 'utf8',
39
+ )
40
+
41
+ return { homeDir }
42
+ }
43
+
44
+ afterEach(async () => {
45
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
46
+ })
47
+
48
+ describe('cc-env CLI init', () => {
49
+ it('migrates env into shell config with --yes', async () => {
50
+ const { homeDir } = await createInitFixture()
51
+
52
+ await execa(
53
+ 'node',
54
+ ['--import', tsxLoader, cliEntry, 'init', '--yes'],
55
+ {
56
+ cwd: repoRoot,
57
+ env: {
58
+ HOME: homeDir,
59
+ },
60
+ },
61
+ )
62
+
63
+ const settings = JSON.parse(
64
+ await readFile(join(homeDir, '.claude', 'settings.json'), 'utf8'),
65
+ ) as { env?: Record<string, string> }
66
+ const settingsLocal = JSON.parse(
67
+ await readFile(join(homeDir, '.claude', 'settings.local.json'), 'utf8'),
68
+ ) as { env?: Record<string, string> }
69
+ const fishConfig = await readFile(join(homeDir, '.config', 'fish', 'config.fish'), 'utf8')
70
+
71
+ expect(settings.env).toEqual({})
72
+ expect(settingsLocal.env).toEqual({})
73
+ expect(fishConfig).toContain('set -gx ANTHROPIC_AUTH_TOKEN "local-token"')
74
+ expect(fishConfig).toContain('set -gx ANTHROPIC_BASE_URL "https://settings.example.com"')
75
+ })
76
+ })