@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,6 @@
1
+ {
2
+ "enabledPlugins": {
3
+ "superpowers@claude-plugins-official": true
4
+ },
5
+ "env": {}
6
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "env": {}
3
+ }
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20.19.2
package/dist/cli.js ADDED
@@ -0,0 +1,266 @@
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
+ import { Command } from 'commander';
7
+ const h = React.createElement;
8
+ import { createInitCommand } from './commands/init.js';
9
+ import { createPresetCreateCommand } from './commands/preset/create.js';
10
+ import { createDeletePresetCommand } from './commands/preset/delete.js';
11
+ import { PresetDeleteApp } from './ink/preset-delete-app.js';
12
+ import { createShowPresetsCommand } from './commands/preset/show.js';
13
+ import { createRestoreCommand } from './commands/restore.js';
14
+ import { createRunCommand } from './commands/run.js';
15
+ import { findClaudeExecutable } from './core/find-claude.js';
16
+ import { InitApp } from './ink/init-app.js';
17
+ import { renderEnvSummary } from './ink/summary.js';
18
+ import { PresetCreateApp } from './ink/preset-create-app.js';
19
+ import { PresetShowApp } from './ink/preset-show-app.js';
20
+ import { RunPresetSelectApp } from './ink/run-preset-select-app.js';
21
+ import { advanceRestoreFlow, createRestoreFlowState } from './flows/restore-flow.js';
22
+ import { RestoreApp } from './ink/restore-app.js';
23
+ import { CliError } from './core/errors.js';
24
+ import { resolveGlobalRoot } from './core/paths.js';
25
+ import { spawnCommand } from './core/spawn.js';
26
+ import { createClaudeSettingsEnvService } from './services/claude-settings-env-service.js';
27
+ import { createHistoryService } from './services/history-service.js';
28
+ import { createPresetService } from './services/preset-service.js';
29
+ import { createProjectEnvService } from './services/project-env-service.js';
30
+ import { createProjectStateService } from './services/project-state-service.js';
31
+ import { createSettingsEnvService } from './services/settings-env-service.js';
32
+ import { createShellEnvService } from './services/shell-env-service.js';
33
+ const program = new Command();
34
+ program.name('cc-env').description('Manage runtime environment variables for Claude Code');
35
+ const homeDir = process.env.HOME ?? process.cwd();
36
+ const cwd = process.cwd();
37
+ const settingsPath = join(cwd, 'settings.json');
38
+ const globalRoot = resolveGlobalRoot();
39
+ const claudeSettingsEnvService = createClaudeSettingsEnvService({ homeDir, cwd });
40
+ const settingsEnvService = createSettingsEnvService({ settingsPath });
41
+ const shellEnvService = createShellEnvService({ homeDir });
42
+ const projectEnvService = createProjectEnvService({ cwd });
43
+ const presetService = createPresetService(globalRoot);
44
+ const historyService = createHistoryService(globalRoot);
45
+ async function runRestoreFlow(context) {
46
+ const state = createRestoreFlowState(context.records);
47
+ const firstRecord = context.records[0];
48
+ if (!firstRecord) {
49
+ render(h(RestoreApp, { state }));
50
+ return undefined;
51
+ }
52
+ if (context.yes) {
53
+ const selectedRecordState = advanceRestoreFlow(state, {
54
+ type: 'select-record',
55
+ timestamp: firstRecord.timestamp
56
+ });
57
+ if (firstRecord.action === 'init') {
58
+ const doneState = advanceRestoreFlow(selectedRecordState, { type: 'confirm' });
59
+ if (doneState.step !== 'done') {
60
+ return undefined;
61
+ }
62
+ return {
63
+ confirmed: true,
64
+ timestamp: firstRecord.timestamp
65
+ };
66
+ }
67
+ const confirmState = advanceRestoreFlow(selectedRecordState, {
68
+ type: 'select-target',
69
+ targetType: firstRecord.targetType,
70
+ ...(firstRecord.targetType === 'preset' ? { targetName: firstRecord.targetName } : {})
71
+ });
72
+ const doneState = advanceRestoreFlow(confirmState, { type: 'confirm' });
73
+ if (doneState.step === 'done' && doneState.targetType === 'preset') {
74
+ return {
75
+ confirmed: true,
76
+ timestamp: doneState.selectedTimestamp,
77
+ targetType: doneState.targetType,
78
+ targetName: doneState.targetName
79
+ };
80
+ }
81
+ if (doneState.step === 'done') {
82
+ return {
83
+ confirmed: true,
84
+ timestamp: doneState.selectedTimestamp,
85
+ targetType: doneState.targetType
86
+ };
87
+ }
88
+ return undefined;
89
+ }
90
+ let result;
91
+ const app = render(h(RestoreApp, {
92
+ state,
93
+ onSubmit: value => {
94
+ result = value;
95
+ }
96
+ }));
97
+ await app.waitUntilExit();
98
+ return result;
99
+ }
100
+ program.exitOverride().configureOutput({
101
+ writeErr: str => {
102
+ if (!str.startsWith('error:')) {
103
+ process.stderr.write(str);
104
+ }
105
+ }
106
+ });
107
+ program
108
+ .command('run [args...]')
109
+ .allowUnknownOption(true)
110
+ .description('Run claude with merged environment variables')
111
+ .option('--dry-run', 'Preview the merged env without executing')
112
+ .option('-y, --yes', 'Auto-select the default preset without interactive prompts')
113
+ .action((args, options) => {
114
+ const rawArgs = args ?? [];
115
+ return createRunCommand({
116
+ claudeSettingsEnvService,
117
+ presetService,
118
+ projectEnvService,
119
+ projectStateService: createProjectStateService(globalRoot),
120
+ findClaude: findClaudeExecutable,
121
+ renderPresetSelect: async ({ presets, defaultIndex }) => {
122
+ let result;
123
+ const app = render(h(RunPresetSelectApp, {
124
+ presets,
125
+ defaultIndex,
126
+ onSubmit: preset => {
127
+ result = preset;
128
+ }
129
+ }));
130
+ await app.waitUntilExit();
131
+ return result;
132
+ },
133
+ spawnCommand
134
+ })({
135
+ args: rawArgs,
136
+ dryRun: options.dryRun ?? false,
137
+ yes: options.yes ?? false,
138
+ cwd
139
+ });
140
+ });
141
+ program
142
+ .command('init')
143
+ .description('Initialize cc-env for the current project')
144
+ .option('-y, --yes', 'Accept all defaults without interactive prompts')
145
+ .action(options => createInitCommand({
146
+ claudeSettingsEnvService,
147
+ shellEnvService,
148
+ historyService,
149
+ renderEnvSummary,
150
+ renderFlow: async (context) => {
151
+ if (context.yes) {
152
+ return {
153
+ selectedKeys: context.requiredKeys,
154
+ confirmed: true
155
+ };
156
+ }
157
+ let result;
158
+ const app = render(h(InitApp, {
159
+ ...context,
160
+ onSubmit: value => {
161
+ result = value;
162
+ }
163
+ }));
164
+ await app.waitUntilExit();
165
+ return result;
166
+ }
167
+ })({
168
+ yes: options.yes
169
+ }));
170
+ program
171
+ .command('restore')
172
+ .description('Restore environment variables from a previous snapshot')
173
+ .option('-y, --yes', 'Accept all defaults without interactive prompts')
174
+ .action(options => createRestoreCommand({
175
+ historyService,
176
+ claudeSettingsEnvService,
177
+ shellEnvService,
178
+ settingsEnvService,
179
+ presetService,
180
+ renderEnvSummary: renderEnvSummary,
181
+ renderFlow: context => runRestoreFlow(context)
182
+ })({
183
+ yes: options.yes
184
+ }));
185
+ const presetCommand = program.command('preset').description('Manage environment presets');
186
+ presetCommand
187
+ .command('show')
188
+ .description('List and view all presets')
189
+ .action(createShowPresetsCommand({
190
+ presetService,
191
+ projectEnvService,
192
+ renderShow: async (presets) => {
193
+ const app = render(h(PresetShowApp, { presets }));
194
+ await app.waitUntilExit();
195
+ }
196
+ }));
197
+ presetCommand
198
+ .command('delete')
199
+ .description('Delete a saved preset')
200
+ .action(createDeletePresetCommand({
201
+ presetService,
202
+ projectEnvService,
203
+ renderDelete: async (presets) => {
204
+ let result;
205
+ const app = render(h(PresetDeleteApp, {
206
+ presets,
207
+ onSubmit: preset => {
208
+ result = preset;
209
+ }
210
+ }));
211
+ await app.waitUntilExit();
212
+ return result;
213
+ }
214
+ }));
215
+ presetCommand
216
+ .command('create')
217
+ .description('Create a new environment preset')
218
+ .action(() => createPresetCreateCommand({
219
+ presetService,
220
+ projectEnvService,
221
+ renderFlow: async () => {
222
+ let result;
223
+ const app = render(h(PresetCreateApp, {
224
+ onSubmit: value => {
225
+ result = value;
226
+ },
227
+ readFile: async (filePath) => {
228
+ const { readEnvFile } = await import('./commands/preset/create.js');
229
+ return readEnvFile(filePath);
230
+ },
231
+ globalPresetPath: name => presetService.getPath(name),
232
+ projectEnvPath: join(cwd, '.cc-env', 'env.json')
233
+ }));
234
+ await app.waitUntilExit();
235
+ return result;
236
+ }
237
+ })({ cwd }));
238
+ function printBanner() {
239
+ const banner = figlet.textSync('CC ENV', { font: 'ANSI Shadow' });
240
+ const line = '─'.repeat(48);
241
+ const styled = gradient(['#00d2ff', '#7b2ff7', '#ff0080'])(banner);
242
+ process.stderr.write(`\n${styled}\x1b[2m\n${line}\x1b[0m\n\n`);
243
+ }
244
+ program.hook('preAction', () => {
245
+ printBanner();
246
+ });
247
+ program.parseAsync(process.argv).catch((error) => {
248
+ if (error instanceof CliError) {
249
+ process.stderr.write(`\n Error: ${error.message}\n\n`);
250
+ process.exitCode = error.exitCode;
251
+ return;
252
+ }
253
+ if (error && typeof error === 'object' && 'code' in error) {
254
+ const { code, message } = error;
255
+ if (code === 'commander.helpDisplayed') {
256
+ process.exitCode = 0;
257
+ return;
258
+ }
259
+ const hint = ` Run "cc-env --help" to see available commands and options.\n`;
260
+ const formatted = message?.replace(/^error:\s*/i, '') ?? 'Unknown error';
261
+ process.stderr.write(`\n Error: ${formatted}\n\n${hint}\n`);
262
+ process.exitCode = 1;
263
+ return;
264
+ }
265
+ throw error;
266
+ });
@@ -0,0 +1,17 @@
1
+ import { toProcessEnvMap } from '../core/process-env.js';
2
+ import { renderEnvSummary } from '../ink/summary.js';
3
+ export function createDebugCommand({ projectEnvService, processEnv }) {
4
+ return async function debug() {
5
+ const projectEnv = await projectEnvService.read();
6
+ await renderEnvSummary({
7
+ title: 'Process Environment',
8
+ description: 'Current process environment variables',
9
+ env: toProcessEnvMap(processEnv)
10
+ });
11
+ await renderEnvSummary({
12
+ title: 'Project Environment',
13
+ description: 'Environment variables from .cc-env/env.json',
14
+ env: projectEnv
15
+ });
16
+ };
17
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { CliError } from '../core/errors.js';
4
+ import { envMapSchema } from '../core/schema.js';
5
+ const h = React.createElement;
6
+ const requiredInitKeys = [
7
+ 'ANTHROPIC_AUTH_TOKEN',
8
+ 'ANTHROPIC_BASE_URL',
9
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
10
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
11
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
12
+ 'ANTHROPIC_REASONING_MODEL',
13
+ ];
14
+ function omitKeys(env, keys) {
15
+ return envMapSchema.parse(Object.fromEntries(Object.entries(env).filter(([key]) => !keys.includes(key))));
16
+ }
17
+ export function createInitCommand({ claudeSettingsEnvService, shellEnvService, historyService, renderFlow, renderEnvSummary, }) {
18
+ return async function init({ yes = false } = {}) {
19
+ const sources = await claudeSettingsEnvService.read();
20
+ if (sources.every((s) => !s.exists)) {
21
+ throw new CliError('No Claude settings files were found');
22
+ }
23
+ const effectiveEnv = envMapSchema.parse(sources.reduce((acc, source) => ({ ...acc, ...source.env }), {}));
24
+ const keys = Object.keys(effectiveEnv).sort();
25
+ const requiredKeys = requiredInitKeys.filter((key) => key in effectiveEnv);
26
+ const sourceFiles = sources.map((s) => s.path);
27
+ const result = await renderFlow({ keys, requiredKeys, yes, sourceFiles });
28
+ if (!result?.confirmed) {
29
+ return;
30
+ }
31
+ const migratedEnv = envMapSchema.parse(Object.fromEntries(result.selectedKeys
32
+ .filter((key) => key in effectiveEnv)
33
+ .map((key) => [key, effectiveEnv[key]])));
34
+ if (Object.keys(migratedEnv).length === 0) {
35
+ throw new CliError('No selected env values found to migrate');
36
+ }
37
+ const initSources = sources.map((source) => ({
38
+ file: source.path,
39
+ backup: envMapSchema.parse(Object.fromEntries(result.selectedKeys
40
+ .filter((key) => key in source.env)
41
+ .map((key) => [key, source.env[key]]))),
42
+ }));
43
+ const timestamp = new Date().toISOString();
44
+ const shellWrites = await shellEnvService.write(migratedEnv);
45
+ await historyService.write({
46
+ timestamp,
47
+ action: 'init',
48
+ migratedKeys: result.selectedKeys,
49
+ sources: initSources,
50
+ shellWrites,
51
+ });
52
+ await claudeSettingsEnvService.write(sources.map((source) => ({
53
+ path: source.path,
54
+ env: omitKeys(source.env, result.selectedKeys),
55
+ })));
56
+ await renderEnvSummary({
57
+ title: 'Migrated',
58
+ env: migratedEnv,
59
+ fromFiles: initSources.map((s) => s.file),
60
+ toFiles: shellWrites.map((sw) => sw.filePath),
61
+ footer: h(Box, { flexDirection: 'column' }, h(Text, { color: 'green' }, 'Init complete'), h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the migrated environment variables to take effect.')),
62
+ });
63
+ };
64
+ }
@@ -0,0 +1,61 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { CliError } from '../../core/errors.js';
5
+ import { ensureGitignoreEntry } from '../../core/gitignore.js';
6
+ import { toProcessEnvMap } from '../../core/process-env.js';
7
+ export async function readEnvFile(filePath) {
8
+ try {
9
+ const content = await readFile(filePath, 'utf8');
10
+ const extension = extname(filePath).toLowerCase();
11
+ if (extension !== '.yaml' && extension !== '.yml' && extension !== '.json') {
12
+ throw new CliError(`Unsupported file format: ${extension}`, 2);
13
+ }
14
+ const parsed = extension === '.yaml' || extension === '.yml'
15
+ ? parseYaml(content)
16
+ : JSON.parse(content);
17
+ const raw = (parsed ?? {});
18
+ const source = extension === '.json'
19
+ && raw
20
+ && typeof raw === 'object'
21
+ && 'env' in raw
22
+ && raw.env
23
+ && typeof raw.env === 'object'
24
+ && !Array.isArray(raw.env)
25
+ ? raw.env
26
+ : raw;
27
+ const env = toProcessEnvMap(source);
28
+ return {
29
+ allKeys: Object.keys(env),
30
+ env,
31
+ };
32
+ }
33
+ catch (error) {
34
+ if (error instanceof CliError)
35
+ throw error;
36
+ throw new CliError(`Failed to read env file: ${filePath}`, 2);
37
+ }
38
+ }
39
+ export function createPresetCreateCommand({ presetService, projectEnvService, renderFlow, ensureGitignore = (dir, entry) => ensureGitignoreEntry(dir, entry), }) {
40
+ return async function createPreset({ cwd }) {
41
+ const result = await renderFlow();
42
+ if (!result)
43
+ return;
44
+ const selectedEnv = {};
45
+ for (const key of result.selectedKeys) {
46
+ selectedEnv[key] = result.env[key] ?? '';
47
+ }
48
+ const timestamp = new Date().toISOString();
49
+ if (result.destination === 'project') {
50
+ await projectEnvService.write(selectedEnv, { name: result.presetName, createdAt: timestamp, updatedAt: timestamp });
51
+ await ensureGitignore(cwd, '.cc-env');
52
+ return;
53
+ }
54
+ await presetService.write({
55
+ name: result.presetName,
56
+ createdAt: timestamp,
57
+ updatedAt: timestamp,
58
+ env: selectedEnv,
59
+ });
60
+ };
61
+ }
@@ -0,0 +1,25 @@
1
+ export function createDeletePresetCommand({ presetService, projectEnvService, renderDelete, }) {
2
+ return async function deletePreset() {
3
+ const names = await presetService.listNames();
4
+ const globalPresets = await Promise.all(names.map((name) => presetService.read(name).then((p) => ({ name, env: p.env, source: 'global' }))));
5
+ const { env: projectEnv, name: projectName } = await projectEnvService.readWithMeta();
6
+ const projectPreset = Object.keys(projectEnv).length > 0
7
+ ? [{ name: projectName ?? 'project', env: projectEnv, source: 'project' }]
8
+ : [];
9
+ const presets = [...projectPreset, ...globalPresets];
10
+ if (presets.length === 0) {
11
+ console.log('No presets found.');
12
+ return;
13
+ }
14
+ const selected = await renderDelete(presets);
15
+ if (!selected)
16
+ return;
17
+ if (selected.source === 'project') {
18
+ await projectEnvService.write({});
19
+ }
20
+ else {
21
+ await presetService.remove(selected.name);
22
+ }
23
+ console.log(`Deleted preset: ${selected.name}`);
24
+ };
25
+ }
@@ -0,0 +1,15 @@
1
+ import { spawnSync as defaultSpawnSync } from 'node:child_process';
2
+ import { CliError } from '../../core/errors.js';
3
+ export function createEditPresetCommand({ presetService, processEnv = process.env, spawnSync = defaultSpawnSync, }) {
4
+ return async function editPreset(name) {
5
+ const editor = processEnv.EDITOR;
6
+ if (!editor) {
7
+ throw new CliError('EDITOR is required to edit a preset');
8
+ }
9
+ const preset = await presetService.read(name);
10
+ const result = spawnSync(editor, [preset.filePath], { stdio: 'inherit' });
11
+ if (result.status !== 0) {
12
+ throw new CliError(`Editor exited with code ${result.status ?? 1}`);
13
+ }
14
+ };
15
+ }
@@ -0,0 +1,16 @@
1
+ export function createListPresetsCommand({ presetService, projectEnvService, renderList, }) {
2
+ return async function listPresets() {
3
+ const names = await presetService.listNames();
4
+ const globalPresets = await Promise.all(names.map((name) => presetService.read(name).then((p) => ({ name, env: p.env, source: 'global' }))));
5
+ const { env: projectEnv, name: projectName } = await projectEnvService.readWithMeta();
6
+ const projectPreset = Object.keys(projectEnv).length > 0
7
+ ? [{ name: projectName ?? 'project', env: projectEnv, source: 'project' }]
8
+ : [];
9
+ const presets = [...projectPreset, ...globalPresets];
10
+ if (presets.length === 0) {
11
+ console.log('No presets found.');
12
+ return;
13
+ }
14
+ await renderList(presets);
15
+ };
16
+ }
@@ -0,0 +1,16 @@
1
+ export function createShowPresetsCommand({ presetService, projectEnvService, renderShow, }) {
2
+ return async function showPresets() {
3
+ const names = await presetService.listNames();
4
+ const globalPresets = await Promise.all(names.map((name) => presetService.read(name).then((p) => ({ name, env: p.env, source: 'global' }))));
5
+ const { env: projectEnv, name: projectName } = await projectEnvService.readWithMeta();
6
+ const projectPreset = Object.keys(projectEnv).length > 0
7
+ ? [{ name: projectName ?? 'project', env: projectEnv, source: 'project' }]
8
+ : [];
9
+ const presets = [...projectPreset, ...globalPresets];
10
+ if (presets.length === 0) {
11
+ console.log('No presets found.');
12
+ return;
13
+ }
14
+ await renderShow(presets);
15
+ };
16
+ }
@@ -0,0 +1,65 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { CliError } from '../core/errors.js';
4
+ const h = React.createElement;
5
+ export function createRestoreCommand({ historyService, claudeSettingsEnvService, shellEnvService, settingsEnvService, presetService, renderFlow, renderEnvSummary, }) {
6
+ return async function restore({ yes = false } = {}) {
7
+ const records = await historyService.list();
8
+ const result = await renderFlow({ records, yes });
9
+ if (!result?.confirmed) {
10
+ return;
11
+ }
12
+ const record = records.find((entry) => entry.timestamp === result.timestamp);
13
+ if (!record) {
14
+ throw new CliError('Restore record not found');
15
+ }
16
+ if (record.action === 'init') {
17
+ const mergedBackup = Object.fromEntries(record.sources.flatMap((s) => Object.entries(s.backup)));
18
+ const current = await claudeSettingsEnvService.read();
19
+ await shellEnvService.removeKeys(record.shellWrites, record.migratedKeys);
20
+ await claudeSettingsEnvService.write(current.map((source) => ({
21
+ path: source.path,
22
+ env: {
23
+ ...source.env,
24
+ ...(record.sources.find((s) => s.file === source.path)?.backup ?? {}),
25
+ },
26
+ })));
27
+ await renderEnvSummary({
28
+ title: 'Restored',
29
+ env: mergedBackup,
30
+ fromFiles: record.shellWrites.map((sw) => sw.filePath),
31
+ toFiles: record.sources.map((s) => s.file),
32
+ footer: h(Box, { flexDirection: 'column' }, h(Text, { color: 'green' }, 'Restore complete'), h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the restored environment variables to take effect.')),
33
+ });
34
+ return;
35
+ }
36
+ if (result.targetType === 'settings') {
37
+ const currentSettings = await settingsEnvService.read();
38
+ await settingsEnvService.write({
39
+ ...currentSettings,
40
+ ...record.backup,
41
+ });
42
+ await renderEnvSummary({
43
+ title: 'Restored to settings',
44
+ env: record.backup,
45
+ footer: h(Box, { flexDirection: 'column' }, h(Text, { color: 'green' }, 'Restore complete'), h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the restored environment variables to take effect.')),
46
+ });
47
+ return;
48
+ }
49
+ const presetName = result.targetName ?? record.targetName;
50
+ const preset = await presetService.read(presetName);
51
+ await presetService.write({
52
+ ...preset,
53
+ updatedAt: new Date().toISOString(),
54
+ env: {
55
+ ...preset.env,
56
+ ...record.backup,
57
+ },
58
+ });
59
+ await renderEnvSummary({
60
+ title: `Restored to preset ${presetName}`,
61
+ env: record.backup,
62
+ footer: h(Box, { flexDirection: 'column' }, h(Text, { color: 'green' }, 'Restore complete'), h(Text, { bold: true, color: 'green' }, 'Please restart your terminal for the restored environment variables to take effect.')),
63
+ });
64
+ };
65
+ }
@@ -0,0 +1,80 @@
1
+ import { CliError } from '../core/errors.js';
2
+ import { formatRunEnvBlock } from '../core/format.js';
3
+ const requiredInitKeys = [
4
+ 'ANTHROPIC_AUTH_TOKEN',
5
+ 'ANTHROPIC_BASE_URL',
6
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
7
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
8
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
9
+ 'ANTHROPIC_REASONING_MODEL',
10
+ ];
11
+ export function createRunCommand({ claudeSettingsEnvService, presetService, projectEnvService, projectStateService, findClaude, renderPresetSelect, spawnCommand, stdout = process.stdout, }) {
12
+ return async function run({ args = [], dryRun = false, yes = false, cwd, }) {
13
+ // Step 0: Check settings files for init-managed keys
14
+ const sources = await claudeSettingsEnvService.read();
15
+ const mergedSettingsEnv = sources.reduce((acc, s) => ({ ...acc, ...s.env }), {});
16
+ const staleKeys = requiredInitKeys.filter((k) => k in mergedSettingsEnv);
17
+ if (staleKeys.length > 0) {
18
+ throw new CliError(`Found init-managed keys in Claude settings:\n\n ${staleKeys.join(', \n ')}. \n\n Run "cc-env init" first.`);
19
+ }
20
+ // Step 1: Collect all presets (project + global)
21
+ const names = await presetService.listNames();
22
+ const globalPresets = await Promise.all(names.map((name) => presetService.read(name).then((p) => ({ name, env: p.env, source: 'global' }))));
23
+ const { env: projectEnv, name: projectName } = await projectEnvService.readWithMeta();
24
+ const projectPreset = Object.keys(projectEnv).length > 0
25
+ ? [{ name: projectName ?? 'project', env: projectEnv, source: 'project' }]
26
+ : [];
27
+ const presets = [...projectPreset, ...globalPresets];
28
+ if (presets.length === 0) {
29
+ throw new CliError('No presets found. Create one with "cc-env preset create".');
30
+ }
31
+ // Step 2: Determine default selection
32
+ const savedRef = await projectStateService.getLastPreset(cwd);
33
+ let defaultIndex = 0;
34
+ if (savedRef) {
35
+ const idx = presets.findIndex((p) => p.name === savedRef.presetName && p.source === savedRef.source);
36
+ if (idx >= 0)
37
+ defaultIndex = idx;
38
+ }
39
+ else if (projectPreset.length > 0) {
40
+ defaultIndex = 0;
41
+ }
42
+ // Step 3: Select preset (interactive or auto)
43
+ let selected;
44
+ if (yes) {
45
+ selected = presets[defaultIndex];
46
+ }
47
+ else {
48
+ selected = await renderPresetSelect({ presets, defaultIndex });
49
+ }
50
+ if (!selected)
51
+ return;
52
+ // Step 4: Save selection
53
+ await projectStateService.saveLastPreset(cwd, {
54
+ presetName: selected.name,
55
+ source: selected.source,
56
+ });
57
+ // Step 5: Resolve claude command
58
+ let command;
59
+ let claudeArgs;
60
+ if (args.length > 0 && args[0] === 'claude') {
61
+ command = 'claude';
62
+ claudeArgs = args.slice(1);
63
+ }
64
+ else {
65
+ command = findClaude();
66
+ claudeArgs = args;
67
+ }
68
+ // Step 6: Print env vars
69
+ const presetKeys = new Set(Object.keys(selected.env));
70
+ const envBlock = formatRunEnvBlock(selected.env, presetKeys);
71
+ stdout.write(`Using preset: ${selected.name} (${selected.source})\n${envBlock}\n\n`);
72
+ if (dryRun) {
73
+ const preview = [command, ...claudeArgs].join(' ');
74
+ stdout.write(`Would run: ${preview}\n`);
75
+ return;
76
+ }
77
+ // Step 7: Spawn
78
+ await spawnCommand(command, claudeArgs, { ...process.env, ...selected.env });
79
+ };
80
+ }
@@ -0,0 +1,11 @@
1
+ export class CliError extends Error {
2
+ exitCode;
3
+ constructor(message, exitCode = 1) {
4
+ super(message);
5
+ this.name = 'CliError';
6
+ this.exitCode = exitCode;
7
+ }
8
+ }
9
+ export function invalidUsage(message) {
10
+ return new CliError(message, 2);
11
+ }