@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.
- package/.claude/settings.json +6 -0
- package/.claude/settings.local.json +3 -0
- package/.nvmrc +1 -0
- package/dist/cli.js +266 -0
- package/dist/commands/debug.js +17 -0
- package/dist/commands/init.js +64 -0
- package/dist/commands/preset/create.js +61 -0
- package/dist/commands/preset/delete.js +25 -0
- package/dist/commands/preset/edit.js +15 -0
- package/dist/commands/preset/list.js +16 -0
- package/dist/commands/preset/show.js +16 -0
- package/dist/commands/restore.js +65 -0
- package/dist/commands/run.js +80 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/find-claude.js +64 -0
- package/dist/core/format.js +23 -0
- package/dist/core/fs.js +12 -0
- package/dist/core/gitignore.js +23 -0
- package/dist/core/lock.js +25 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/mask.js +13 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process-env.js +4 -0
- package/dist/core/schema.js +38 -0
- package/dist/core/spawn.js +26 -0
- package/dist/flows/init-flow.js +35 -0
- package/dist/flows/preset-create-flow.js +80 -0
- package/dist/flows/restore-flow.js +75 -0
- package/dist/ink/init-app.js +54 -0
- package/dist/ink/preset-create-app.js +271 -0
- package/dist/ink/preset-delete-app.js +47 -0
- package/dist/ink/preset-list-app.js +27 -0
- package/dist/ink/preset-show-app.js +27 -0
- package/dist/ink/restore-app.js +102 -0
- package/dist/ink/run-preset-select-app.js +31 -0
- package/dist/ink/summary.js +28 -0
- package/dist/services/claude-settings-env-service.js +55 -0
- package/dist/services/config-service.js +26 -0
- package/dist/services/history-service.js +39 -0
- package/dist/services/preset-service.js +61 -0
- package/dist/services/project-env-service.js +90 -0
- package/dist/services/project-state-service.js +26 -0
- package/dist/services/runtime-env-service.js +13 -0
- package/dist/services/settings-env-service.js +36 -0
- package/dist/services/shell-env-service.js +77 -0
- package/docs/product-specs/index.draft.md +106 -0
- package/docs/product-specs/index.md +911 -0
- package/docs/product-specs/optional.md +42 -0
- package/docs/references/claude-code-env.md +224 -0
- package/docs/superpowers/plans/2026-04-24-cc-env-init-shell-migration.md +1331 -0
- package/docs/superpowers/plans/2026-04-24-cc-env.md +1666 -0
- package/docs/superpowers/plans/2026-04-26-preset-create-interactive-refactor.md +1432 -0
- package/docs/superpowers/specs/2026-04-24-cc-env-design.md +438 -0
- package/docs/superpowers/specs/2026-04-24-cc-env-init-shell-migration-design.md +181 -0
- package/docs/superpowers/specs/2026-04-26-preset-create-interactive-refactor-design.md +78 -0
- package/package.json +55 -0
- package/src/cli.ts +337 -0
- package/src/commands/init.ts +139 -0
- package/src/commands/preset/create.ts +96 -0
- package/src/commands/preset/delete.ts +62 -0
- package/src/commands/preset/show.ts +51 -0
- package/src/commands/restore.ts +150 -0
- package/src/commands/run.ts +158 -0
- package/src/core/errors.ts +13 -0
- package/src/core/find-claude.ts +70 -0
- package/src/core/format.ts +29 -0
- package/src/core/fs.ts +18 -0
- package/src/core/gitignore.ts +26 -0
- package/src/core/logger.ts +11 -0
- package/src/core/mask.ts +17 -0
- package/src/core/paths.ts +41 -0
- package/src/core/process-env.ts +11 -0
- package/src/core/schema.ts +55 -0
- package/src/core/spawn.ts +36 -0
- package/src/flows/init-flow.ts +61 -0
- package/src/flows/preset-create-flow.ts +129 -0
- package/src/flows/restore-flow.ts +144 -0
- package/src/ink/init-app.tsx +110 -0
- package/src/ink/preset-create-app.tsx +451 -0
- package/src/ink/preset-delete-app.tsx +114 -0
- package/src/ink/preset-show-app.tsx +76 -0
- package/src/ink/restore-app.tsx +230 -0
- package/src/ink/run-preset-select-app.tsx +83 -0
- package/src/ink/summary.tsx +91 -0
- package/src/services/claude-settings-env-service.ts +72 -0
- package/src/services/history-service.ts +48 -0
- package/src/services/preset-service.ts +72 -0
- package/src/services/project-env-service.ts +128 -0
- package/src/services/project-state-service.ts +31 -0
- package/src/services/settings-env-service.ts +40 -0
- package/src/services/shell-env-service.ts +112 -0
- package/src/types.d.ts +19 -0
- package/tests/cli/help.test.ts +133 -0
- package/tests/cli/init.test.ts +76 -0
- package/tests/cli/restore.test.ts +172 -0
- package/tests/commands/create.test.ts +263 -0
- package/tests/commands/output.test.ts +119 -0
- package/tests/commands/run.test.ts +218 -0
- package/tests/core/gitignore.test.ts +98 -0
- package/tests/core/paths.test.ts +24 -0
- package/tests/core/schema-mask.test.ts +182 -0
- package/tests/core/spawn.test.ts +47 -0
- package/tests/flows/init-flow.test.ts +40 -0
- package/tests/flows/preset-create-flow.test.ts +225 -0
- package/tests/flows/restore-flow.test.ts +157 -0
- package/tests/integration/init-restore.test.ts +406 -0
- package/tests/services/claude-shell.test.ts +183 -0
- package/tests/services/storage.test.ts +143 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|
package/src/core/mask.ts
ADDED
|
@@ -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>
|