@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,51 @@
1
+ import type { EnvMap } from '../../core/schema.js'
2
+
3
+ type PresetService = {
4
+ listNames: () => Promise<string[]>
5
+ read: (name: string) => Promise<{ env: EnvMap }>
6
+ }
7
+
8
+ type ProjectEnvService = {
9
+ readWithMeta: () => Promise<{ env: EnvMap; name?: string | undefined }>
10
+ }
11
+
12
+ export type PresetSource = 'global' | 'project'
13
+
14
+ export type PresetShowItem = {
15
+ name: string
16
+ env: EnvMap
17
+ source: PresetSource
18
+ }
19
+
20
+ export function createShowPresetsCommand({
21
+ presetService,
22
+ projectEnvService,
23
+ renderShow,
24
+ }: {
25
+ presetService: PresetService
26
+ projectEnvService: ProjectEnvService
27
+ renderShow: (presets: Array<PresetShowItem>) => Promise<void>
28
+ }) {
29
+ return async function showPresets(): Promise<void> {
30
+ const names = await presetService.listNames()
31
+ const globalPresets = await Promise.all(
32
+ names.map((name) =>
33
+ presetService.read(name).then((p) => ({ name, env: p.env, source: 'global' as const })),
34
+ ),
35
+ )
36
+
37
+ const { env: projectEnv, name: projectName } = await projectEnvService.readWithMeta()
38
+ const projectPreset =
39
+ Object.keys(projectEnv).length > 0
40
+ ? [{ name: projectName ?? 'project', env: projectEnv, source: 'project' as const }]
41
+ : []
42
+
43
+ const presets = [...projectPreset, ...globalPresets]
44
+ if (presets.length === 0) {
45
+ console.log('No presets found.')
46
+ return
47
+ }
48
+
49
+ await renderShow(presets)
50
+ }
51
+ }
@@ -0,0 +1,150 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+
4
+ import { CliError } from '../core/errors.js'
5
+ import type { EnvMap, HistoryRecord, Preset } 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
+ type HistoryService = {
12
+ list: () => Promise<HistoryRecord[]>
13
+ }
14
+
15
+ type ClaudeSettingsEnvService = {
16
+ read: () => Promise<ClaudeSettingsSource[]>
17
+ write: (sources: Array<{ path: string; env: EnvMap }>) => Promise<void>
18
+ }
19
+
20
+ type ShellEnvService = {
21
+ removeKeys: (shellWrites: ShellWriteRecord[], keys: string[]) => Promise<void>
22
+ }
23
+
24
+ type SettingsEnvService = {
25
+ read: () => Promise<EnvMap>
26
+ write: (env: EnvMap) => Promise<unknown>
27
+ }
28
+
29
+ type PresetService = {
30
+ read: (name: string) => Promise<Preset>
31
+ write: (preset: Preset) => Promise<unknown>
32
+ }
33
+
34
+ type RestoreFlowResult = {
35
+ confirmed?: boolean
36
+ timestamp?: string
37
+ targetType?: 'settings' | 'preset'
38
+ targetName?: string
39
+ }
40
+
41
+ export function createRestoreCommand({
42
+ historyService,
43
+ claudeSettingsEnvService,
44
+ shellEnvService,
45
+ settingsEnvService,
46
+ presetService,
47
+ renderFlow,
48
+ renderEnvSummary,
49
+ }: {
50
+ historyService: HistoryService
51
+ claudeSettingsEnvService: ClaudeSettingsEnvService
52
+ shellEnvService: ShellEnvService
53
+ settingsEnvService: SettingsEnvService
54
+ presetService: PresetService
55
+ renderFlow: (context: {
56
+ records: HistoryRecord[]
57
+ yes: boolean
58
+ }) => Promise<RestoreFlowResult | void> | RestoreFlowResult | void
59
+ renderEnvSummary: (props: {
60
+ title: string
61
+ env: EnvMap
62
+ fromFiles?: string[]
63
+ toFiles?: string[]
64
+ footer?: React.ReactNode
65
+ }) => Promise<void>
66
+ }) {
67
+ return async function restore({ yes = false }: { yes?: boolean } = {}): Promise<void> {
68
+ const records = await historyService.list()
69
+ const result = await renderFlow({ records, yes })
70
+
71
+ if (!result?.confirmed) {
72
+ return
73
+ }
74
+
75
+ const record = records.find((entry) => entry.timestamp === result.timestamp)
76
+
77
+ if (!record) {
78
+ throw new CliError('Restore record not found')
79
+ }
80
+
81
+ if (record.action === 'init') {
82
+ const mergedBackup = Object.fromEntries(
83
+ record.sources.flatMap((s) => Object.entries(s.backup)),
84
+ )
85
+
86
+ const current = await claudeSettingsEnvService.read()
87
+ await shellEnvService.removeKeys(record.shellWrites, record.migratedKeys)
88
+ await claudeSettingsEnvService.write(
89
+ current.map((source) => ({
90
+ path: source.path,
91
+ env: {
92
+ ...source.env,
93
+ ...(record.sources.find((s) => s.file === source.path)?.backup ?? {}),
94
+ },
95
+ })),
96
+ )
97
+
98
+ await renderEnvSummary({
99
+ title: 'Restored',
100
+ env: mergedBackup,
101
+ fromFiles: record.shellWrites.map((sw) => sw.filePath),
102
+ toFiles: record.sources.map((s) => s.file),
103
+ footer: h(Box, { flexDirection: 'column' },
104
+ h(Text, { color: 'green' }, 'Restore complete'),
105
+ h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the restored environment variables to take effect.'),
106
+ ),
107
+ })
108
+ return
109
+ }
110
+
111
+ if (result.targetType === 'settings') {
112
+ const currentSettings = await settingsEnvService.read()
113
+ await settingsEnvService.write({
114
+ ...currentSettings,
115
+ ...record.backup,
116
+ })
117
+
118
+ await renderEnvSummary({
119
+ title: 'Restored to settings',
120
+ env: record.backup,
121
+ footer: h(Box, { flexDirection: 'column' },
122
+ h(Text, { color: 'green' }, 'Restore complete'),
123
+ h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the restored environment variables to take effect.'),
124
+ ),
125
+ })
126
+ return
127
+ }
128
+
129
+ const presetName = result.targetName ?? record.targetName
130
+ const preset = await presetService.read(presetName)
131
+
132
+ await presetService.write({
133
+ ...preset,
134
+ updatedAt: new Date().toISOString(),
135
+ env: {
136
+ ...preset.env,
137
+ ...record.backup,
138
+ },
139
+ })
140
+
141
+ await renderEnvSummary({
142
+ title: `Restored to preset ${presetName}`,
143
+ env: record.backup,
144
+ footer: h(Box, { flexDirection: 'column' },
145
+ h(Text, { color: 'green' }, 'Restore complete'),
146
+ h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the restored environment variables to take effect.'),
147
+ ),
148
+ })
149
+ }
150
+ }
@@ -0,0 +1,158 @@
1
+ import { CliError } from '../core/errors.js'
2
+ import { formatRunEnvBlock } from '../core/format.js'
3
+ import type { EnvMap } from '../core/schema.js'
4
+
5
+ const requiredInitKeys = [
6
+ 'ANTHROPIC_AUTH_TOKEN',
7
+ 'ANTHROPIC_BASE_URL',
8
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
9
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
10
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
11
+ 'ANTHROPIC_REASONING_MODEL',
12
+ ] as const
13
+
14
+ type PresetSelectItem = {
15
+ name: string
16
+ env: EnvMap
17
+ source: 'global' | 'project'
18
+ }
19
+
20
+ type ClaudeSettingsEnvService = {
21
+ read: () => Promise<Array<{ path: string; exists: boolean; env: EnvMap }>>
22
+ }
23
+
24
+ type PresetService = {
25
+ listNames: () => Promise<string[]>
26
+ read: (name: string) => Promise<{ env: EnvMap }>
27
+ }
28
+
29
+ type ProjectEnvService = {
30
+ readWithMeta: () => Promise<{ env: EnvMap; name?: string | undefined }>
31
+ }
32
+
33
+ type ProjectStateService = {
34
+ getLastPreset: (cwd: string) => Promise<{ presetName: string; source: 'global' | 'project' } | undefined>
35
+ saveLastPreset: (cwd: string, ref: { presetName: string; source: 'global' | 'project' }) => Promise<void>
36
+ }
37
+
38
+ type FindClaude = () => string
39
+
40
+ type RenderPresetSelect = (input: {
41
+ presets: Array<PresetSelectItem>
42
+ defaultIndex: number
43
+ }) => Promise<PresetSelectItem | undefined>
44
+
45
+ export function createRunCommand({
46
+ claudeSettingsEnvService,
47
+ presetService,
48
+ projectEnvService,
49
+ projectStateService,
50
+ findClaude,
51
+ renderPresetSelect,
52
+ spawnCommand,
53
+ stdout = process.stdout,
54
+ }: {
55
+ claudeSettingsEnvService: ClaudeSettingsEnvService
56
+ presetService: PresetService
57
+ projectEnvService: ProjectEnvService
58
+ projectStateService: ProjectStateService
59
+ findClaude: FindClaude
60
+ renderPresetSelect: RenderPresetSelect
61
+ spawnCommand: (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise<void>
62
+ stdout?: Pick<NodeJS.WriteStream, 'write'>
63
+ }) {
64
+ return async function run({
65
+ args = [],
66
+ dryRun = false,
67
+ yes = false,
68
+ cwd,
69
+ }: {
70
+ args?: string[]
71
+ dryRun?: boolean
72
+ yes?: boolean
73
+ cwd: string
74
+ }): Promise<void> {
75
+ // Step 0: Check settings files for init-managed keys
76
+ const sources = await claudeSettingsEnvService.read()
77
+ const mergedSettingsEnv = sources.reduce<EnvMap>(
78
+ (acc, s) => ({ ...acc, ...s.env }),
79
+ {} as EnvMap,
80
+ )
81
+ const staleKeys = requiredInitKeys.filter((k) => k in mergedSettingsEnv)
82
+ if (staleKeys.length > 0) {
83
+ throw new CliError(
84
+ `Found init-managed keys in Claude settings:\n\n ${staleKeys.join(', \n ')}. \n\n Run "cc-env init" first.`,
85
+ )
86
+ }
87
+
88
+ // Step 1: Collect all presets (project + global)
89
+ const names = await presetService.listNames()
90
+ const globalPresets = await Promise.all(
91
+ names.map((name) =>
92
+ presetService.read(name).then((p) => ({ name, env: p.env, source: 'global' as const })),
93
+ ),
94
+ )
95
+ const { env: projectEnv, name: projectName } = await projectEnvService.readWithMeta()
96
+ const projectPreset =
97
+ Object.keys(projectEnv).length > 0
98
+ ? [{ name: projectName ?? 'project', env: projectEnv, source: 'project' as const }]
99
+ : []
100
+
101
+ const presets = [...projectPreset, ...globalPresets]
102
+ if (presets.length === 0) {
103
+ throw new CliError('No presets found. Create one with "cc-env preset create".')
104
+ }
105
+
106
+ // Step 2: Determine default selection
107
+ const savedRef = await projectStateService.getLastPreset(cwd)
108
+ let defaultIndex = 0
109
+ if (savedRef) {
110
+ const idx = presets.findIndex(
111
+ (p) => p.name === savedRef.presetName && p.source === savedRef.source,
112
+ )
113
+ if (idx >= 0) defaultIndex = idx
114
+ } else if (projectPreset.length > 0) {
115
+ defaultIndex = 0
116
+ }
117
+
118
+ // Step 3: Select preset (interactive or auto)
119
+ let selected: PresetSelectItem | undefined
120
+ if (yes) {
121
+ selected = presets[defaultIndex]
122
+ } else {
123
+ selected = await renderPresetSelect({ presets, defaultIndex })
124
+ }
125
+ if (!selected) return
126
+
127
+ // Step 4: Save selection
128
+ await projectStateService.saveLastPreset(cwd, {
129
+ presetName: selected.name,
130
+ source: selected.source,
131
+ })
132
+
133
+ // Step 5: Resolve claude command
134
+ let command: string
135
+ let claudeArgs: string[]
136
+ if (args.length > 0 && args[0] === 'claude') {
137
+ command = 'claude'
138
+ claudeArgs = args.slice(1)
139
+ } else {
140
+ command = findClaude()
141
+ claudeArgs = args
142
+ }
143
+
144
+ // Step 6: Print env vars
145
+ const presetKeys = new Set(Object.keys(selected.env))
146
+ const envBlock = formatRunEnvBlock(selected.env, presetKeys)
147
+ stdout.write(`Using preset: ${selected.name} (${selected.source})\n${envBlock}\n\n`)
148
+
149
+ if (dryRun) {
150
+ const preview = [command, ...claudeArgs].join(' ')
151
+ stdout.write(`Would run: ${preview}\n`)
152
+ return
153
+ }
154
+
155
+ // Step 7: Spawn
156
+ await spawnCommand(command, claudeArgs, { ...process.env, ...selected.env })
157
+ }
158
+ }
@@ -0,0 +1,13 @@
1
+ export class CliError extends Error {
2
+ readonly exitCode: number
3
+
4
+ constructor(message: string, exitCode = 1) {
5
+ super(message)
6
+ this.name = 'CliError'
7
+ this.exitCode = exitCode
8
+ }
9
+ }
10
+
11
+ export function invalidUsage(message: string): CliError {
12
+ return new CliError(message, 2)
13
+ }
@@ -0,0 +1,70 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { CliError } from './errors.js'
6
+
7
+ function resolveToJsFile(filePath: string): string {
8
+ try {
9
+ const realPath = realpathSync(filePath)
10
+ if (realPath.endsWith('.js')) return realPath
11
+ if (existsSync(realPath)) {
12
+ const content = readFileSync(realPath, 'utf-8')
13
+ if (
14
+ content.startsWith('#!/usr/bin/env node') ||
15
+ /^#!.*\/node$/m.test(content) ||
16
+ content.includes('require(') ||
17
+ content.includes('import ')
18
+ ) {
19
+ return realPath
20
+ }
21
+ }
22
+
23
+ for (const candidate of [
24
+ realPath + '.js',
25
+ realPath.replace(/\/bin\//, '/lib/') + '.js',
26
+ realPath.replace(/\/\.bin\//, '/lib/bin/') + '.js',
27
+ ]) {
28
+ if (existsSync(candidate)) return candidate
29
+ }
30
+
31
+ return realPath
32
+ } catch {
33
+ return filePath
34
+ }
35
+ }
36
+
37
+ export function findClaudeExecutable(): string {
38
+ try {
39
+ let claudePath = execSync('which claude', { encoding: 'utf-8' }).trim()
40
+ const aliasMatch = claudePath.match(/:\s*aliased to\s+(.+)$/)
41
+ if (aliasMatch?.[1]) claudePath = aliasMatch[1]
42
+
43
+ if (existsSync(claudePath)) {
44
+ const content = readFileSync(claudePath, 'utf-8')
45
+ if (content.startsWith('#!/bin/bash')) {
46
+ const execMatch = content.match(/exec\s+"([^"]+)"/)
47
+ if (execMatch?.[1]) return resolveToJsFile(execMatch[1])
48
+ }
49
+ }
50
+
51
+ return resolveToJsFile(claudePath)
52
+ } catch {}
53
+
54
+ const home = process.env.HOME ?? process.cwd()
55
+ const localWrapper = join(home, '.claude', 'local', 'claude')
56
+ if (existsSync(localWrapper)) {
57
+ const content = readFileSync(localWrapper, 'utf-8')
58
+ if (content.startsWith('#!/bin/bash')) {
59
+ const execMatch = content.match(/exec\s+"([^"]+)"/)
60
+ if (execMatch?.[1]) return resolveToJsFile(execMatch[1])
61
+ }
62
+ }
63
+
64
+ const localBin = join(home, '.claude', 'local', 'node_modules', '.bin', 'claude')
65
+ if (existsSync(localBin)) return resolveToJsFile(localBin)
66
+
67
+ throw new CliError(
68
+ 'Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code',
69
+ )
70
+ }
@@ -0,0 +1,29 @@
1
+ import { maskValue } from './mask.js'
2
+ import type { EnvMap } from './schema.js'
3
+
4
+ export function formatEnvBlock(env: EnvMap): string {
5
+ return Object.entries(env)
6
+ .sort(([left], [right]) => left.localeCompare(right))
7
+ .map(([key, value]) => `${key}=${maskValue(key, value)}`)
8
+ .join('\n')
9
+ }
10
+
11
+ export function formatRunEnvBlock(env: EnvMap, presetKeys: ReadonlySet<string>): string {
12
+ const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
13
+ const presetEntries = entries.filter(([key]) => presetKeys.has(key))
14
+ const otherCount = entries.length - presetEntries.length
15
+
16
+ const lines = presetEntries.map(([key, value]) => `${key}=${maskValue(key, value)}`)
17
+ if (otherCount > 0) {
18
+ lines.push(`+${otherCount} other env vars applied`)
19
+ }
20
+
21
+ return lines.join('\n')
22
+ }
23
+
24
+ export function formatRestorePreview(env: EnvMap): string {
25
+ return Object.entries(env)
26
+ .sort(([left], [right]) => left.localeCompare(right))
27
+ .map(([key, value]) => `${key}=${value}`)
28
+ .join('\n')
29
+ }
package/src/core/fs.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
2
+ import { basename, dirname, join } from 'node:path'
3
+
4
+ export async function ensureParentDir(filePath: string): Promise<void> {
5
+ await mkdir(dirname(filePath), { recursive: true })
6
+ }
7
+
8
+ export async function atomicWriteFile(filePath: string, content: string): Promise<void> {
9
+ const parentDir = dirname(filePath)
10
+ const tempFilePath = join(
11
+ parentDir,
12
+ `.${basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`,
13
+ )
14
+
15
+ await ensureParentDir(filePath)
16
+ await writeFile(tempFilePath, content, 'utf8')
17
+ await rename(tempFilePath, filePath)
18
+ }
@@ -0,0 +1,26 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ export function isGitRepo(dir: string): boolean {
6
+ return existsSync(join(dir, '.git'))
7
+ }
8
+
9
+ export async function ensureGitignoreEntry(dir: string, entry: string): Promise<void> {
10
+ if (!isGitRepo(dir)) return
11
+
12
+ const gitignorePath = join(dir, '.gitignore')
13
+ let content = ''
14
+
15
+ try {
16
+ content = await readFile(gitignorePath, 'utf8')
17
+ } catch {
18
+ // .gitignore doesn't exist — will create it
19
+ }
20
+
21
+ const lines = content.split('\n')
22
+ if (lines.some((line) => line === entry || line === `${entry}/`)) return
23
+
24
+ const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''
25
+ await writeFile(gitignorePath, `${content}${separator}${entry}\n`, 'utf8')
26
+ }
@@ -0,0 +1,11 @@
1
+ import pino from 'pino'
2
+
3
+ import { ensureParentDir } from './fs.js'
4
+ import { resolveLogPath } from './paths.js'
5
+
6
+ export async function createLogger(globalRoot: string) {
7
+ const logPath = resolveLogPath(globalRoot)
8
+ await ensureParentDir(logPath)
9
+
10
+ return pino(pino.destination(logPath))
11
+ }
@@ -0,0 +1,17 @@
1
+ const sensitiveKeyPattern = /(_TOKEN|_KEY|_SECRET|_PASSWORD)$/i
2
+
3
+ export function isSensitiveKey(key: string): boolean {
4
+ return sensitiveKeyPattern.test(key)
5
+ }
6
+
7
+ export function maskValue(key: string, value: string): string {
8
+ if (!isSensitiveKey(key)) {
9
+ return value
10
+ }
11
+
12
+ if (value.length <= 8) {
13
+ return '*'.repeat(value.length)
14
+ }
15
+
16
+ return `${value.slice(0, 9)}********`
17
+ }
@@ -0,0 +1,41 @@
1
+ import { join } from 'node:path'
2
+
3
+ export function resolveGlobalRoot(globalRoot?: string): string {
4
+ return globalRoot ?? join(process.env.HOME ?? process.cwd(), '.cc-env')
5
+ }
6
+
7
+ export function resolveClaudeSettingsPath(homeDir = process.env.HOME ?? process.cwd()): string {
8
+ return join(homeDir, '.claude', 'settings.json')
9
+ }
10
+
11
+ export function resolveClaudeSettingsLocalPath(homeDir = process.env.HOME ?? process.cwd()): string {
12
+ return join(homeDir, '.claude', 'settings.local.json')
13
+ }
14
+
15
+ export function resolveProjectSettingsPath(cwd = process.cwd()): string {
16
+ return join(cwd, '.claude', 'settings.json')
17
+ }
18
+
19
+ export function resolveProjectSettingsLocalPath(cwd = process.cwd()): string {
20
+ return join(cwd, '.claude', 'settings.local.json')
21
+ }
22
+
23
+ export function resolveShellConfigPaths(homeDir = process.env.HOME ?? process.cwd()) {
24
+ return {
25
+ zsh: join(homeDir, '.zshrc'),
26
+ bash: join(homeDir, '.bashrc'),
27
+ fish: join(homeDir, '.config', 'fish', 'config.fish'),
28
+ }
29
+ }
30
+
31
+ export function resolvePresetPath(globalRoot: string, name: string): string {
32
+ return join(globalRoot, 'presets', `${name}.json`)
33
+ }
34
+
35
+ export function resolveHistoryPath(globalRoot: string, timestamp: string): string {
36
+ return join(globalRoot, 'history', `${timestamp.replaceAll(':', '-')}.json`)
37
+ }
38
+
39
+ export function resolveLogPath(globalRoot: string): string {
40
+ return join(globalRoot, 'logs', 'app.log')
41
+ }
@@ -0,0 +1,11 @@
1
+ import { envMapSchema, type EnvMap } from './schema.js'
2
+
3
+ export function toProcessEnvMap(input: Record<string, unknown>): EnvMap {
4
+ return envMapSchema.parse(
5
+ Object.fromEntries(
6
+ Object.entries(input).filter(
7
+ ([key, value]) => typeof value === 'string' && /^[A-Z0-9_]+$/.test(key),
8
+ ),
9
+ ),
10
+ )
11
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod'
2
+
3
+ const envKeySchema = z.string().regex(/^[A-Z0-9_]+$/)
4
+
5
+ export const envMapSchema = z.record(
6
+ envKeySchema,
7
+ z.unknown()
8
+ .refine((value) => value === null || typeof value !== 'object')
9
+ .transform((value) => String(value)),
10
+ )
11
+
12
+ export const presetSchema = z.object({
13
+ name: z.string(),
14
+ createdAt: z.string().datetime({ offset: true }),
15
+ updatedAt: z.string().datetime({ offset: true }),
16
+ env: envMapSchema,
17
+ })
18
+
19
+ const shellWriteSchema = z.object({
20
+ shell: z.enum(['zsh', 'bash', 'fish']),
21
+ filePath: z.string(),
22
+ env: envMapSchema,
23
+ })
24
+
25
+ const sourceEntrySchema = z.object({
26
+ file: z.string(),
27
+ backup: envMapSchema,
28
+ })
29
+
30
+ const initHistorySchema = z.object({
31
+ timestamp: z.string().datetime({ offset: true }),
32
+ action: z.literal('init'),
33
+ migratedKeys: z.array(envKeySchema),
34
+ sources: z.array(sourceEntrySchema),
35
+ shellWrites: z.array(shellWriteSchema),
36
+ })
37
+
38
+ const restoreHistorySchema = z.object({
39
+ timestamp: z.string().datetime({ offset: true }),
40
+ action: z.literal('restore'),
41
+ backup: envMapSchema,
42
+ targetType: z.enum(['settings', 'preset']),
43
+ targetName: z.string(),
44
+ })
45
+
46
+ export const historySchema = z.discriminatedUnion('action', [
47
+ initHistorySchema,
48
+ restoreHistorySchema,
49
+ ])
50
+
51
+ export type EnvMap = z.infer<typeof envMapSchema>
52
+ export type SourceEntry = z.infer<typeof sourceEntrySchema>
53
+ export type Preset = z.infer<typeof presetSchema>
54
+ export type InitHistoryRecord = z.infer<typeof initHistorySchema>
55
+ export type HistoryRecord = z.infer<typeof historySchema>