@lkangd/cc-env 1.1.0 → 1.1.2

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 (72) hide show
  1. package/LICENSE +15 -0
  2. package/dist/cli.js +3 -4
  3. package/package.json +8 -1
  4. package/.claude/settings.json +0 -6
  5. package/.claude/settings.local.json +0 -8
  6. package/.nvmrc +0 -1
  7. package/CHANGELOG.md +0 -66
  8. package/docs/product-specs/index.draft.md +0 -106
  9. package/docs/product-specs/index.md +0 -911
  10. package/docs/product-specs/optional.md +0 -42
  11. package/docs/references/claude-code-env.md +0 -224
  12. package/docs/superpowers/plans/2026-04-24-cc-env-init-shell-migration.md +0 -1331
  13. package/docs/superpowers/plans/2026-04-24-cc-env.md +0 -1666
  14. package/docs/superpowers/plans/2026-04-26-preset-create-interactive-refactor.md +0 -1432
  15. package/docs/superpowers/specs/2026-04-24-cc-env-design.md +0 -438
  16. package/docs/superpowers/specs/2026-04-24-cc-env-init-shell-migration-design.md +0 -181
  17. package/docs/superpowers/specs/2026-04-26-preset-create-interactive-refactor-design.md +0 -78
  18. package/src/cli.ts +0 -339
  19. package/src/commands/init.ts +0 -139
  20. package/src/commands/preset/create.ts +0 -96
  21. package/src/commands/preset/delete.ts +0 -62
  22. package/src/commands/preset/show.ts +0 -51
  23. package/src/commands/restore.ts +0 -150
  24. package/src/commands/run.ts +0 -158
  25. package/src/core/errors.ts +0 -13
  26. package/src/core/find-claude.ts +0 -70
  27. package/src/core/format.ts +0 -29
  28. package/src/core/fs.ts +0 -18
  29. package/src/core/gitignore.ts +0 -26
  30. package/src/core/logger.ts +0 -11
  31. package/src/core/mask.ts +0 -17
  32. package/src/core/paths.ts +0 -41
  33. package/src/core/process-env.ts +0 -11
  34. package/src/core/schema.ts +0 -55
  35. package/src/core/spawn.ts +0 -36
  36. package/src/flows/init-flow.ts +0 -61
  37. package/src/flows/preset-create-flow.ts +0 -129
  38. package/src/flows/restore-flow.ts +0 -144
  39. package/src/ink/init-app.tsx +0 -110
  40. package/src/ink/preset-create-app.tsx +0 -451
  41. package/src/ink/preset-delete-app.tsx +0 -114
  42. package/src/ink/preset-show-app.tsx +0 -76
  43. package/src/ink/restore-app.tsx +0 -230
  44. package/src/ink/run-preset-select-app.tsx +0 -83
  45. package/src/ink/summary.tsx +0 -91
  46. package/src/services/claude-settings-env-service.ts +0 -72
  47. package/src/services/history-service.ts +0 -48
  48. package/src/services/preset-service.ts +0 -72
  49. package/src/services/project-env-service.ts +0 -128
  50. package/src/services/project-state-service.ts +0 -31
  51. package/src/services/settings-env-service.ts +0 -40
  52. package/src/services/shell-env-service.ts +0 -112
  53. package/src/types.d.ts +0 -19
  54. package/tests/cli/help.test.ts +0 -133
  55. package/tests/cli/init.test.ts +0 -76
  56. package/tests/cli/restore.test.ts +0 -172
  57. package/tests/commands/create.test.ts +0 -263
  58. package/tests/commands/output.test.ts +0 -119
  59. package/tests/commands/run.test.ts +0 -218
  60. package/tests/core/gitignore.test.ts +0 -98
  61. package/tests/core/paths.test.ts +0 -24
  62. package/tests/core/schema-mask.test.ts +0 -182
  63. package/tests/core/spawn.test.ts +0 -47
  64. package/tests/flows/init-flow.test.ts +0 -40
  65. package/tests/flows/preset-create-flow.test.ts +0 -225
  66. package/tests/flows/restore-flow.test.ts +0 -157
  67. package/tests/integration/init-restore.test.ts +0 -406
  68. package/tests/services/claude-shell.test.ts +0 -183
  69. package/tests/services/storage.test.ts +0 -143
  70. package/tsconfig.build.json +0 -9
  71. package/tsconfig.json +0 -22
  72. package/vitest.config.ts +0 -8
@@ -1,1666 +0,0 @@
1
- # cc-env Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Build a TypeScript CLI that composes settings, process, preset, and project environment variables deterministically, then either previews or injects them into a child process.
6
-
7
- **Architecture:** Keep Commander command parsing thin, move all stateful behavior into services, and keep interactive Ink flows driven by pure transition helpers so the hard logic is unit-testable without a TTY. Reuse one runtime env service for `run` and `debug`, and guard every state-changing write with file locks.
8
-
9
- **Tech Stack:** Node.js 20, TypeScript, Commander, React Ink, zod, fs-extra, yaml, cross-spawn, proper-lockfile, pino, Vitest
10
-
11
- ---
12
-
13
- ## File Structure
14
-
15
- ### Files to modify
16
- - Modify: `package.json` — add runtime/dev dependencies, bin entry, scripts
17
-
18
- ### Files to create
19
- - Create: `tsconfig.json` — TypeScript compiler config
20
- - Create: `vitest.config.ts` — test config
21
- - Create: `src/cli.ts` — Commander entrypoint and top-level error handling
22
- - Create: `src/core/errors.ts` — `CliError` and exit-code mapping
23
- - Create: `src/core/schema.ts` — zod schemas and env validators
24
- - Create: `src/core/mask.ts` — secret detection and masked formatting
25
- - Create: `src/core/paths.ts` — global/project path resolution
26
- - Create: `src/core/fs.ts` — path directory helpers for file parents and atomic write helpers
27
- - Create: `src/core/lock.ts` — `proper-lockfile` wrapper
28
- - Create: `src/core/logger.ts` — pino logger with secret-safe payloads
29
- - Create: `src/core/format.ts` — table/output helpers for list/show/debug/dry-run
30
- - Create: `src/core/process-env.ts` — safe filtering of `process.env` into a flat env map
31
- - Create: `src/core/spawn.ts` — `cross-spawn` wrapper for runtime execution
32
- - Create: `src/services/config-service.ts` — read/write `~/.cc-env/config.json`
33
- - Create: `src/services/preset-service.ts` — preset CRUD and file-path lookup
34
- - Create: `src/services/history-service.ts` — history read/write/list
35
- - Create: `src/services/settings-env-service.ts` — read/update `~/.claude/settings.json`
36
- - Create: `src/services/project-env-service.ts` — read/write `./.cc-env/env.json|yaml`
37
- - Create: `src/services/runtime-env-service.ts` — merge settings/process/preset/project env in one place
38
- - Create: `src/commands/run.ts` — `cc-env run`
39
- - Create: `src/commands/debug.ts` — `cc-env debug`
40
- - Create: `src/commands/init.ts` — `cc-env init`
41
- - Create: `src/commands/restore.ts` — `cc-env restore`
42
- - Create: `src/commands/preset/list.ts` — `cc-env preset list`
43
- - Create: `src/commands/preset/show.ts` — `cc-env preset show`
44
- - Create: `src/commands/preset/delete.ts` — `cc-env preset delete`
45
- - Create: `src/commands/preset/edit.ts` — `cc-env preset edit`
46
- - Create: `src/commands/preset/create.ts` — `cc-env preset create`
47
- - Create: `src/flows/preset-create-flow.ts` — pure state machine for create flow
48
- - Create: `src/flows/init-flow.ts` — pure state machine for init flow
49
- - Create: `src/flows/restore-flow.ts` — pure state machine for restore flow
50
- - Create: `src/ink/preset-create-app.tsx` — Ink UI wrapper for create flow
51
- - Create: `src/ink/init-app.tsx` — Ink UI wrapper for init flow
52
- - Create: `src/ink/restore-app.tsx` — Ink UI wrapper for restore flow
53
- - Create: `tests/cli/help.test.ts` — CLI smoke tests
54
- - Create: `tests/core/schema-mask.test.ts` — schema/mask tests
55
- - Create: `tests/services/storage.test.ts` — config/preset/history tests
56
- - Create: `tests/services/runtime-env.test.ts` — settings/project/runtime merge tests
57
- - Create: `tests/commands/output.test.ts` — list/show/debug output tests
58
- - Create: `tests/commands/run.test.ts` — run/dry-run tests
59
- - Create: `tests/flows/preset-create-flow.test.ts` — create flow tests
60
- - Create: `tests/flows/init-flow.test.ts` — init flow tests
61
- - Create: `tests/flows/restore-flow.test.ts` — restore flow tests
62
- - Create: `tests/integration/init-restore.test.ts` — migration and restore integration tests
63
-
64
- ### Responsibility boundaries
65
- - `src/core/*` contains small, dependency-light helpers and shared types.
66
- - `src/services/*` owns filesystem, schema validation, and merge behavior.
67
- - `src/commands/*` turns CLI args into service calls and prints user-facing output.
68
- - `src/flows/*` encodes interaction state so selection logic is testable without Ink.
69
- - `src/ink/*` handles `useInput`, `useApp`, and rendering only.
70
-
71
- ---
72
-
73
- ### Task 1: Bootstrap the TypeScript CLI skeleton
74
-
75
- **Files:**
76
- - Modify: `package.json`
77
- - Create: `tsconfig.json`
78
- - Create: `vitest.config.ts`
79
- - Create: `src/cli.ts`
80
- - Test: `tests/cli/help.test.ts`
81
-
82
- - [ ] **Step 1: Write the failing CLI help smoke test**
83
-
84
- ```ts
85
- import {describe, expect, it} from 'vitest';
86
- import {execa} from 'execa';
87
- import path from 'node:path';
88
-
89
- describe('cc-env CLI', () => {
90
- it('prints the top-level commands', async () => {
91
- const cliPath = path.resolve(process.cwd(), 'src/cli.ts');
92
- const result = await execa('npx', ['tsx', cliPath, '--help']);
93
-
94
- expect(result.stdout).toContain('run');
95
- expect(result.stdout).toContain('init');
96
- expect(result.stdout).toContain('restore');
97
- expect(result.stdout).toContain('preset');
98
- expect(result.stdout).toContain('debug');
99
- });
100
- });
101
- ```
102
-
103
- - [ ] **Step 2: Run the test to verify it fails**
104
-
105
- Run: `npm test -- tests/cli/help.test.ts`
106
- Expected: FAIL with `Cannot find module '/.../src/cli.ts'` or missing script/dependency errors.
107
-
108
- - [ ] **Step 3: Add project tooling and the minimal CLI entrypoint**
109
-
110
- ```json
111
- {
112
- "name": "cc-env",
113
- "version": "1.0.0",
114
- "type": "module",
115
- "bin": {
116
- "cc-env": "dist/cli.js"
117
- },
118
- "scripts": {
119
- "build": "tsc -p tsconfig.json",
120
- "dev": "tsx src/cli.ts",
121
- "test": "vitest run"
122
- },
123
- "dependencies": {
124
- "@inkjs/ui": "^2.0.0",
125
- "commander": "^14.0.0",
126
- "cross-spawn": "^7.0.6",
127
- "fs-extra": "^11.3.2",
128
- "ink": "^6.3.1",
129
- "pino": "^10.0.0",
130
- "proper-lockfile": "^4.1.2",
131
- "react": "^19.1.0",
132
- "yaml": "^2.8.1",
133
- "zod": "^4.1.5"
134
- },
135
- "devDependencies": {
136
- "@types/fs-extra": "^11.0.4",
137
- "@types/node": "^24.3.0",
138
- "@types/react": "^19.1.10",
139
- "execa": "^9.6.0",
140
- "tsx": "^4.20.3",
141
- "typescript": "^5.9.2",
142
- "vitest": "^3.2.4"
143
- }
144
- }
145
- ```
146
-
147
- ```json
148
- {
149
- "compilerOptions": {
150
- "target": "ES2022",
151
- "module": "NodeNext",
152
- "moduleResolution": "NodeNext",
153
- "jsx": "react-jsx",
154
- "outDir": "dist",
155
- "rootDir": ".",
156
- "strict": true,
157
- "esModuleInterop": true,
158
- "skipLibCheck": true,
159
- "resolveJsonModule": true,
160
- "types": ["node", "vitest/globals"]
161
- },
162
- "include": ["src", "tests", "vitest.config.ts"]
163
- }
164
- ```
165
-
166
- ```ts
167
- import {defineConfig} from 'vitest/config';
168
-
169
- export default defineConfig({
170
- test: {
171
- environment: 'node',
172
- include: ['tests/**/*.test.ts']
173
- }
174
- });
175
- ```
176
-
177
- ```ts
178
- import {Command} from 'commander';
179
-
180
- const program = new Command();
181
-
182
- program
183
- .name('cc-env')
184
- .description('Inject deterministic env into a child process')
185
- .command('run')
186
- .description('Run a command with merged env');
187
-
188
- program.command('init').description('Migrate env from ~/.claude/settings.json');
189
- program.command('restore').description('Restore env from history');
190
- program.command('preset').description('Manage presets');
191
- program.command('debug').description('Show effective env');
192
-
193
- await program.parseAsync(process.argv);
194
- ```
195
-
196
- - [ ] **Step 4: Run the smoke test again**
197
-
198
- Run: `npm test -- tests/cli/help.test.ts`
199
- Expected: PASS
200
-
201
- - [ ] **Step 5: Commit**
202
-
203
- ```bash
204
- git add package.json tsconfig.json vitest.config.ts src/cli.ts tests/cli/help.test.ts
205
- git commit -m "chore: scaffold TypeScript CLI"
206
- ```
207
-
208
- ---
209
-
210
- ### Task 2: Add shared errors, schemas, and secret masking
211
-
212
- **Files:**
213
- - Create: `src/core/errors.ts`
214
- - Create: `src/core/schema.ts`
215
- - Create: `src/core/mask.ts`
216
- - Test: `tests/core/schema-mask.test.ts`
217
-
218
- - [ ] **Step 1: Write failing tests for validation and masking**
219
-
220
- ```ts
221
- import {describe, expect, it} from 'vitest';
222
- import {envMapSchema, presetSchema} from '../../src/core/schema.js';
223
- import {maskValue, isSensitiveKey} from '../../src/core/mask.js';
224
-
225
- describe('schema and mask helpers', () => {
226
- it('accepts a flat env map with uppercase keys', () => {
227
- expect(envMapSchema.parse({ANTHROPIC_BASE_URL: 'https://api.example.com'})).toEqual({
228
- ANTHROPIC_BASE_URL: 'https://api.example.com'
229
- });
230
- });
231
-
232
- it('rejects nested env values', () => {
233
- expect(() => envMapSchema.parse({BROKEN: {nested: 'value'}})).toThrow();
234
- });
235
-
236
- it('rejects lowercase keys', () => {
237
- expect(() => envMapSchema.parse({bad_key: 'value'})).toThrow();
238
- });
239
-
240
- it('masks sensitive values by key', () => {
241
- expect(isSensitiveKey('ANTHROPIC_AUTH_TOKEN')).toBe(true);
242
- expect(maskValue('ANTHROPIC_AUTH_TOKEN', 'sk-1234567890')).toBe('sk-123456********');
243
- });
244
-
245
- it('leaves non-sensitive values untouched', () => {
246
- expect(maskValue('ANTHROPIC_BASE_URL', 'https://api.example.com')).toBe('https://api.example.com');
247
- });
248
-
249
- it('validates preset shape', () => {
250
- expect(
251
- presetSchema.parse({
252
- name: 'openai',
253
- createdAt: '2026-04-24T10:00:00Z',
254
- updatedAt: '2026-04-24T10:00:00Z',
255
- env: {ANTHROPIC_AUTH_TOKEN: 'secret'}
256
- }).name
257
- ).toBe('openai');
258
- });
259
- });
260
- ```
261
-
262
- - [ ] **Step 2: Run the test to verify it fails**
263
-
264
- Run: `npm test -- tests/core/schema-mask.test.ts`
265
- Expected: FAIL with missing module errors for `schema.js` and `mask.js`.
266
-
267
- - [ ] **Step 3: Implement the shared core helpers**
268
-
269
- ```ts
270
- export class CliError extends Error {
271
- readonly exitCode: number;
272
-
273
- constructor(message: string, exitCode = 1) {
274
- super(message);
275
- this.name = 'CliError';
276
- this.exitCode = exitCode;
277
- }
278
- }
279
-
280
- export const invalidUsage = (message: string) => new CliError(message, 2);
281
- ```
282
-
283
- ```ts
284
- import {z} from 'zod';
285
-
286
- const envKeySchema = z.string().regex(/^[A-Z0-9_]+$/);
287
- const envValueSchema = z.string();
288
-
289
- export const envMapSchema = z.record(envKeySchema, envValueSchema);
290
-
291
- export const presetSchema = z.object({
292
- name: z.string().min(1),
293
- createdAt: z.string().datetime(),
294
- updatedAt: z.string().datetime(),
295
- env: envMapSchema
296
- });
297
-
298
- export const historySchema = z.object({
299
- timestamp: z.string().datetime(),
300
- action: z.enum(['init', 'restore']),
301
- movedKeys: z.array(envKeySchema),
302
- backup: envMapSchema,
303
- targetType: z.enum(['settings', 'preset']),
304
- targetName: z.string().min(1).optional()
305
- });
306
-
307
- export const configSchema = z.object({
308
- defaultPreset: z.string().min(1).optional()
309
- });
310
-
311
- export type Preset = z.infer<typeof presetSchema>;
312
- export type HistoryRecord = z.infer<typeof historySchema>;
313
- export type Config = z.infer<typeof configSchema>;
314
- export type EnvMap = z.infer<typeof envMapSchema>;
315
- ```
316
-
317
- ```ts
318
- const SENSITIVE_SUFFIXES = ['_TOKEN', '_KEY', '_SECRET', '_PASSWORD'];
319
-
320
- export const isSensitiveKey = (key: string) =>
321
- SENSITIVE_SUFFIXES.some((suffix) => key.endsWith(suffix));
322
-
323
- export const maskValue = (key: string, value: string) => {
324
- if (!isSensitiveKey(key)) {
325
- return value;
326
- }
327
-
328
- if (value.length <= 8) {
329
- return '*'.repeat(value.length);
330
- }
331
-
332
- return `${value.slice(0, 9)}********`;
333
- };
334
- ```
335
-
336
- - [ ] **Step 4: Run the tests again**
337
-
338
- Run: `npm test -- tests/core/schema-mask.test.ts`
339
- Expected: PASS
340
-
341
- - [ ] **Step 5: Commit**
342
-
343
- ```bash
344
- git add src/core/errors.ts src/core/schema.ts src/core/mask.ts tests/core/schema-mask.test.ts
345
- git commit -m "feat: add env validation and masking helpers"
346
- ```
347
-
348
- ---
349
-
350
- ### Task 3: Add path, filesystem, lock, config, preset, and history services
351
-
352
- **Files:**
353
- - Create: `src/core/paths.ts`
354
- - Create: `src/core/fs.ts`
355
- - Create: `src/core/lock.ts`
356
- - Create: `src/core/logger.ts`
357
- - Create: `src/services/config-service.ts`
358
- - Create: `src/services/preset-service.ts`
359
- - Create: `src/services/history-service.ts`
360
- - Test: `tests/services/storage.test.ts`
361
-
362
- - [ ] **Step 1: Write failing storage tests**
363
-
364
- ```ts
365
- import {beforeEach, describe, expect, it} from 'vitest';
366
- import fs from 'fs-extra';
367
- import os from 'node:os';
368
- import path from 'node:path';
369
- import {createConfigService} from '../../src/services/config-service.js';
370
- import {createPresetService} from '../../src/services/preset-service.js';
371
- import {createHistoryService} from '../../src/services/history-service.js';
372
-
373
- describe('storage services', () => {
374
- let root: string;
375
-
376
- beforeEach(async () => {
377
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-env-storage-'));
378
- });
379
-
380
- it('reads and writes the default preset config', async () => {
381
- const service = createConfigService({globalRoot: root});
382
-
383
- await service.write({defaultPreset: 'openai'});
384
-
385
- expect(await service.read()).toEqual({defaultPreset: 'openai'});
386
- });
387
-
388
- it('creates, reads, lists, and deletes presets', async () => {
389
- const service = createPresetService({globalRoot: root});
390
-
391
- await service.write('openai', {ANTHROPIC_BASE_URL: 'https://api.openai.com'});
392
-
393
- expect((await service.read('openai')).env.ANTHROPIC_BASE_URL).toBe('https://api.openai.com');
394
- expect(await service.listNames()).toEqual(['openai']);
395
-
396
- await service.remove('openai');
397
- await expect(service.read('openai')).rejects.toThrow('Preset not found: openai');
398
- });
399
-
400
- it('persists history records', async () => {
401
- const service = createHistoryService({globalRoot: root});
402
-
403
- await service.write({
404
- timestamp: '2026-04-24T10:00:00Z',
405
- action: 'init',
406
- movedKeys: ['ANTHROPIC_BASE_URL'],
407
- backup: {ANTHROPIC_BASE_URL: 'https://api.anthropic.com'},
408
- targetType: 'preset',
409
- targetName: 'openai'
410
- });
411
-
412
- expect((await service.list())[0].targetName).toBe('openai');
413
- });
414
- });
415
- ```
416
-
417
- - [ ] **Step 2: Run the tests to verify they fail**
418
-
419
- Run: `npm test -- tests/services/storage.test.ts`
420
- Expected: FAIL with missing module errors for the storage services.
421
-
422
- - [ ] **Step 3: Implement path and storage services**
423
-
424
- ```ts
425
- import os from 'node:os';
426
- import path from 'node:path';
427
-
428
- export const resolveGlobalRoot = (override?: string) => override ?? path.join(os.homedir(), '.cc-env');
429
- export const resolveConfigPath = (globalRoot: string) => path.join(globalRoot, 'config.json');
430
- export const resolvePresetPath = (globalRoot: string, name: string) => path.join(globalRoot, 'presets', `${name}.json`);
431
- export const resolveHistoryPath = (globalRoot: string, timestamp: string) =>
432
- path.join(globalRoot, 'history', `${timestamp.replace(/:/g, '-')}.json`);
433
- export const resolveLogPath = (globalRoot: string) => path.join(globalRoot, 'logs', 'cc-env.log');
434
- ```
435
-
436
- ```ts
437
- import lockfile from 'proper-lockfile';
438
-
439
- export const withFileLock = async <T>(filePath: string, run: () => Promise<T>) => {
440
- const release = await lockfile.lock(filePath, {realpath: false, retries: {retries: 3, factor: 1}});
441
-
442
- try {
443
- return await run();
444
- } finally {
445
- await release();
446
- }
447
- };
448
- ```
449
-
450
- ```ts
451
- import pino from 'pino';
452
- import fs from 'fs-extra';
453
- import {dirname} from 'node:path';
454
- import {resolveLogPath} from './paths.js';
455
-
456
- export const createLogger = async (globalRoot: string) => {
457
- const destination = resolveLogPath(globalRoot);
458
- await fs.ensureDir(dirname(destination));
459
- return pino(pino.destination(destination));
460
- };
461
- ```
462
-
463
- ```ts
464
- import fs from 'fs-extra';
465
- import {dirname} from 'node:path';
466
-
467
- export const ensureParentDir = async (filePath: string) => {
468
- await fs.ensureDir(dirname(filePath));
469
- };
470
- ```
471
-
472
- ```ts
473
- import fs from 'fs-extra';
474
- import {withFileLock} from '../core/lock.js';
475
- import {ensureParentDir} from '../core/fs.js';
476
- import {configSchema, type Config} from '../core/schema.js';
477
- import {resolveConfigPath, resolveGlobalRoot} from '../core/paths.js';
478
-
479
- export const createConfigService = ({globalRoot = resolveGlobalRoot()}: {globalRoot?: string} = {}) => ({
480
- async read(): Promise<Config> {
481
- const filePath = resolveConfigPath(globalRoot);
482
- if (!(await fs.pathExists(filePath))) return {};
483
- return configSchema.parse(await fs.readJson(filePath));
484
- },
485
- async write(config: Config) {
486
- const filePath = resolveConfigPath(globalRoot);
487
- await ensureParentDir(filePath);
488
- if (!(await fs.pathExists(filePath))) {
489
- await fs.writeJson(filePath, {}, {spaces: 2});
490
- }
491
- await withFileLock(filePath, async () => {
492
- await fs.writeJson(filePath, configSchema.parse(config), {spaces: 2});
493
- });
494
- }
495
- });
496
- ```
497
-
498
- ```ts
499
- import fs from 'fs-extra';
500
- import {CliError} from '../core/errors.js';
501
- import {ensureParentDir} from '../core/fs.js';
502
- import {withFileLock} from '../core/lock.js';
503
- import {presetSchema, type EnvMap} from '../core/schema.js';
504
- import {resolveGlobalRoot, resolvePresetPath} from '../core/paths.js';
505
-
506
- export const createPresetService = ({globalRoot = resolveGlobalRoot()}: {globalRoot?: string} = {}) => ({
507
- getPath(name: string) {
508
- return resolvePresetPath(globalRoot, name);
509
- },
510
- async write(name: string, env: EnvMap) {
511
- const filePath = resolvePresetPath(globalRoot, name);
512
- await ensureParentDir(filePath);
513
- if (!(await fs.pathExists(filePath))) {
514
- await fs.writeJson(filePath, {}, {spaces: 2});
515
- }
516
- return withFileLock(filePath, async () => {
517
- const now = new Date().toISOString();
518
- const previous = await fs.pathExists(filePath) ? presetSchema.safeParse(await fs.readJson(filePath)) : undefined;
519
- const preset = presetSchema.parse({
520
- name,
521
- createdAt: previous?.success ? previous.data.createdAt : now,
522
- updatedAt: now,
523
- env
524
- });
525
- await fs.writeJson(filePath, preset, {spaces: 2});
526
- return {...preset, filePath};
527
- });
528
- },
529
- async read(name: string) {
530
- const filePath = resolvePresetPath(globalRoot, name);
531
- if (!(await fs.pathExists(filePath))) throw new CliError(`Preset not found: ${name}`);
532
- const preset = presetSchema.parse(await fs.readJson(filePath));
533
- return {...preset, filePath};
534
- },
535
- async listNames() {
536
- const dir = resolvePresetPath(globalRoot, 'example').replace(/\/example\.json$/, '');
537
- if (!(await fs.pathExists(dir))) return [];
538
- return (await fs.readdir(dir)).filter((file) => file.endsWith('.json')).map((file) => file.replace(/\.json$/, '')).sort();
539
- },
540
- async remove(name: string) {
541
- const filePath = resolvePresetPath(globalRoot, name);
542
- if (!(await fs.pathExists(filePath))) throw new CliError(`Preset not found: ${name}`);
543
- await withFileLock(filePath, async () => {
544
- await fs.remove(filePath);
545
- });
546
- }
547
- });
548
- ```
549
-
550
- ```ts
551
- import fs from 'fs-extra';
552
- import {ensureParentDir} from '../core/fs.js';
553
- import {withFileLock} from '../core/lock.js';
554
- import {historySchema, type HistoryRecord} from '../core/schema.js';
555
- import {resolveGlobalRoot, resolveHistoryPath} from '../core/paths.js';
556
-
557
- export const createHistoryService = ({globalRoot = resolveGlobalRoot()}: {globalRoot?: string} = {}) => ({
558
- async write(record: HistoryRecord) {
559
- const validated = historySchema.parse(record);
560
- const filePath = resolveHistoryPath(globalRoot, validated.timestamp);
561
- await ensureParentDir(filePath);
562
- if (!(await fs.pathExists(filePath))) {
563
- await fs.writeJson(filePath, {}, {spaces: 2});
564
- }
565
- await withFileLock(filePath, async () => {
566
- await fs.writeJson(filePath, validated, {spaces: 2});
567
- });
568
- },
569
- async list() {
570
- const dir = resolveHistoryPath(globalRoot, '2026-01-01T00-00-00.000Z').replace(/\/2026-01-01T00-00-00\.000Z\.json$/, '');
571
- if (!(await fs.pathExists(dir))) return [];
572
- const files = (await fs.readdir(dir)).filter((file) => file.endsWith('.json')).sort().reverse();
573
- return Promise.all(files.map((file) => fs.readJson(`${dir}/${file}`).then(historySchema.parse)));
574
- }
575
- });
576
- ```
577
-
578
- - [ ] **Step 4: Run the storage tests again**
579
-
580
- Run: `npm test -- tests/services/storage.test.ts`
581
- Expected: PASS
582
-
583
- - [ ] **Step 5: Commit**
584
-
585
- ```bash
586
- git add src/core/paths.ts src/core/lock.ts src/core/logger.ts src/services/config-service.ts src/services/preset-service.ts src/services/history-service.ts tests/services/storage.test.ts
587
- git commit -m "feat: add preset and history storage services"
588
- ```
589
-
590
- ---
591
-
592
- ### Task 4: Add settings env, project env, and runtime merge services
593
-
594
- **Files:**
595
- - Create: `src/services/settings-env-service.ts`
596
- - Create: `src/services/project-env-service.ts`
597
- - Create: `src/services/runtime-env-service.ts`
598
- - Test: `tests/services/runtime-env.test.ts`
599
-
600
- - [ ] **Step 1: Write failing tests for env loading and precedence**
601
-
602
- ```ts
603
- import {beforeEach, describe, expect, it} from 'vitest';
604
- import fs from 'fs-extra';
605
- import os from 'node:os';
606
- import path from 'node:path';
607
- import {createSettingsEnvService} from '../../src/services/settings-env-service.js';
608
- import {createProjectEnvService} from '../../src/services/project-env-service.js';
609
- import {createRuntimeEnvService} from '../../src/services/runtime-env-service.js';
610
-
611
- describe('runtime env service', () => {
612
- let root: string;
613
- let cwd: string;
614
-
615
- beforeEach(async () => {
616
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-env-global-'));
617
- cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-env-project-'));
618
- });
619
-
620
- it('loads settings env from ~/.claude/settings.json', async () => {
621
- const service = createSettingsEnvService({settingsPath: path.join(root, 'settings.json')});
622
-
623
- await fs.writeJson(path.join(root, 'settings.json'), {env: {ANTHROPIC_BASE_URL: 'https://settings.example.com'}});
624
-
625
- expect(await service.read()).toEqual({ANTHROPIC_BASE_URL: 'https://settings.example.com'});
626
- });
627
-
628
- it('fails when both project env files exist', async () => {
629
- await fs.ensureDir(path.join(cwd, '.cc-env'));
630
- await fs.writeJson(path.join(cwd, '.cc-env', 'env.json'), {ONE: '1'});
631
- await fs.writeFile(path.join(cwd, '.cc-env', 'env.yaml'), 'TWO: 2\n');
632
-
633
- const service = createProjectEnvService({cwd});
634
-
635
- await expect(service.read()).rejects.toThrow('Project env conflict: env.json and env.yaml both exist');
636
- });
637
-
638
- it('merges settings, process, preset, then project env', async () => {
639
- const runtime = createRuntimeEnvService();
640
-
641
- const env = await runtime.merge({
642
- settingsEnv: {BASE_URL: 'settings'},
643
- processEnv: {BASE_URL: 'process'},
644
- presetEnv: {BASE_URL: 'preset'},
645
- projectEnv: {BASE_URL: 'project'}
646
- });
647
-
648
- expect(env.BASE_URL).toBe('project');
649
- });
650
- });
651
- ```
652
-
653
- - [ ] **Step 2: Run the test to verify it fails**
654
-
655
- Run: `npm test -- tests/services/runtime-env.test.ts`
656
- Expected: FAIL with missing service modules.
657
-
658
- - [ ] **Step 3: Implement the env source services**
659
-
660
- ```ts
661
- import fs from 'fs-extra';
662
- import {envMapSchema} from '../core/schema.js';
663
-
664
- export const createSettingsEnvService = ({settingsPath}: {settingsPath: string}) => ({
665
- async read() {
666
- if (!(await fs.pathExists(settingsPath))) return {};
667
- const json = await fs.readJson(settingsPath);
668
- return envMapSchema.parse(json.env ?? {});
669
- },
670
- async write(env: Record<string, string>) {
671
- const json = (await fs.pathExists(settingsPath)) ? await fs.readJson(settingsPath) : {};
672
- json.env = envMapSchema.parse(env);
673
- await fs.ensureDir(new URL('..', `file://${settingsPath}`).pathname);
674
- await fs.writeJson(settingsPath, json, {spaces: 2});
675
- }
676
- });
677
- ```
678
-
679
- ```ts
680
- import fs from 'fs-extra';
681
- import path from 'node:path';
682
- import YAML from 'yaml';
683
- import {CliError} from '../core/errors.js';
684
- import {envMapSchema, type EnvMap} from '../core/schema.js';
685
-
686
- export const createProjectEnvService = ({cwd}: {cwd: string}) => {
687
- const jsonPath = path.join(cwd, '.cc-env', 'env.json');
688
- const yamlPath = path.join(cwd, '.cc-env', 'env.yaml');
689
-
690
- return {
691
- async read(): Promise<EnvMap> {
692
- const hasJson = await fs.pathExists(jsonPath);
693
- const hasYaml = await fs.pathExists(yamlPath);
694
-
695
- if (hasJson && hasYaml) {
696
- throw new CliError('Project env conflict: env.json and env.yaml both exist');
697
- }
698
-
699
- if (hasJson) return envMapSchema.parse(await fs.readJson(jsonPath));
700
- if (hasYaml) return envMapSchema.parse(YAML.parse(await fs.readFile(yamlPath, 'utf8')) ?? {});
701
- return {};
702
- },
703
- async write(env: EnvMap) {
704
- const hasYaml = await fs.pathExists(yamlPath);
705
- const targetPath = hasYaml ? yamlPath : jsonPath;
706
- await fs.ensureDir(path.dirname(targetPath));
707
- if (hasYaml) {
708
- await fs.writeFile(targetPath, YAML.stringify(envMapSchema.parse(env)));
709
- } else {
710
- await fs.writeJson(targetPath, envMapSchema.parse(env), {spaces: 2});
711
- }
712
- }
713
- };
714
- };
715
- ```
716
-
717
- ```ts
718
- import {envMapSchema, type EnvMap} from '../core/schema.js';
719
-
720
- export const createRuntimeEnvService = () => ({
721
- async merge({
722
- settingsEnv,
723
- processEnv,
724
- presetEnv,
725
- projectEnv
726
- }: {
727
- settingsEnv: EnvMap;
728
- processEnv: EnvMap;
729
- presetEnv: EnvMap;
730
- projectEnv: EnvMap;
731
- }) {
732
- return envMapSchema.parse({
733
- ...settingsEnv,
734
- ...processEnv,
735
- ...presetEnv,
736
- ...projectEnv
737
- });
738
- }
739
- });
740
- ```
741
-
742
- - [ ] **Step 4: Run the tests again**
743
-
744
- Run: `npm test -- tests/services/runtime-env.test.ts`
745
- Expected: PASS
746
-
747
- - [ ] **Step 5: Commit**
748
-
749
- ```bash
750
- git add src/services/settings-env-service.ts src/services/project-env-service.ts src/services/runtime-env-service.ts tests/services/runtime-env.test.ts
751
- git commit -m "feat: add env source and merge services"
752
- ```
753
-
754
- ---
755
-
756
- ### Task 5: Add process env filtering plus file/inline preset creation
757
-
758
- **Files:**
759
- - Create: `src/core/process-env.ts`
760
- - Modify: `src/commands/preset/create.ts`
761
- - Test: `tests/commands/create.test.ts`
762
-
763
- - [ ] **Step 1: Write failing tests for process env filtering and non-interactive create modes**
764
-
765
- ```ts
766
- import {describe, expect, it, vi} from 'vitest';
767
- import {toProcessEnvMap} from '../../src/core/process-env.js';
768
- import {createPresetCreateCommand} from '../../src/commands/preset/create.js';
769
-
770
- describe('preset create helpers', () => {
771
- it('filters process.env to string env keys only', () => {
772
- expect(
773
- toProcessEnvMap({ANTHROPIC_BASE_URL: 'https://api.example.com', EMPTY: undefined, PATH: '/bin'})
774
- ).toEqual({ANTHROPIC_BASE_URL: 'https://api.example.com', PATH: '/bin'});
775
- });
776
-
777
- it('creates a preset from inline KEY=VALUE pairs', async () => {
778
- const write = vi.fn();
779
- const command = createPresetCreateCommand({
780
- presetService: {write},
781
- projectEnvService: {write: vi.fn()},
782
- renderFlow: vi.fn()
783
- });
784
-
785
- await command({file: undefined, name: 'openai', project: false}, ['ANTHROPIC_BASE_URL=https://api.openai.com']);
786
-
787
- expect(write).toHaveBeenCalledWith('openai', {ANTHROPIC_BASE_URL: 'https://api.openai.com'});
788
- });
789
- });
790
- ```
791
-
792
- - [ ] **Step 2: Run the test to verify it fails**
793
-
794
- Run: `npm test -- tests/commands/create.test.ts`
795
- Expected: FAIL with missing helper or command behavior.
796
-
797
- - [ ] **Step 3: Implement process env filtering and non-interactive create paths**
798
-
799
- ```ts
800
- import {envMapSchema} from './schema.js';
801
-
802
- export const toProcessEnvMap = (input: NodeJS.ProcessEnv) =>
803
- envMapSchema.parse(
804
- Object.fromEntries(Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
805
- );
806
- ```
807
-
808
- ```ts
809
- import fs from 'fs-extra';
810
- import YAML from 'yaml';
811
- import {CliError} from '../../core/errors.js';
812
- import {envMapSchema} from '../../core/schema.js';
813
-
814
- const parseInlinePairs = (pairs: string[]) =>
815
- envMapSchema.parse(
816
- Object.fromEntries(
817
- pairs.map((pair) => {
818
- const index = pair.indexOf('=');
819
- if (index <= 0) throw new CliError(`Invalid env pair: ${pair}`, 2);
820
- return [pair.slice(0, index), pair.slice(index + 1)];
821
- })
822
- )
823
- );
824
-
825
- export const createPresetCreateCommand = ({presetService, projectEnvService, renderFlow}: any) =>
826
- async ({file, name, project}: {file?: string; name?: string; project?: boolean}, pairs: string[]) => {
827
- if (file) {
828
- const raw = await fs.readFile(file, 'utf8');
829
- const parsed = file.endsWith('.yaml') || file.endsWith('.yml') ? YAML.parse(raw) : JSON.parse(raw);
830
- const env = envMapSchema.parse(parsed);
831
- if (project) {
832
- await projectEnvService.write(env);
833
- return;
834
- }
835
- if (!name) throw new CliError('Preset name is required', 2);
836
- await presetService.write(name, env);
837
- return;
838
- }
839
-
840
- if (pairs.length > 0) {
841
- const env = parseInlinePairs(pairs);
842
- if (project) {
843
- await projectEnvService.write(env);
844
- return;
845
- }
846
- if (!name) throw new CliError('Preset name is required', 2);
847
- await presetService.write(name, env);
848
- return;
849
- }
850
-
851
- await renderFlow();
852
- };
853
- ```
854
-
855
- - [ ] **Step 4: Run the tests again**
856
-
857
- Run: `npm test -- tests/commands/create.test.ts`
858
- Expected: PASS
859
-
860
- - [ ] **Step 5: Commit**
861
-
862
- ```bash
863
- git add src/core/process-env.ts src/commands/preset/create.ts tests/commands/create.test.ts
864
- git commit -m "feat: add non-interactive preset creation"
865
- ```
866
-
867
- ---
868
-
869
- ### Task 6: Add output formatting plus list, show, debug, delete, and edit commands
870
-
871
- **Files:**
872
- - Create: `src/core/format.ts`
873
- - Create: `src/commands/preset/list.ts`
874
- - Create: `src/commands/preset/show.ts`
875
- - Create: `src/commands/preset/delete.ts`
876
- - Create: `src/commands/preset/edit.ts`
877
- - Create: `src/commands/debug.ts`
878
- - Modify: `src/cli.ts`
879
- - Test: `tests/commands/output.test.ts`
880
-
881
- - [ ] **Step 1: Write failing tests for non-interactive output commands**
882
-
883
- ```ts
884
- import {describe, expect, it, vi} from 'vitest';
885
- import {formatEnvBlock} from '../../src/core/format.js';
886
- import {createShowPresetCommand} from '../../src/commands/preset/show.js';
887
- import {createDebugCommand} from '../../src/commands/debug.js';
888
-
889
- describe('output commands', () => {
890
- it('masks sensitive values when formatting env output', () => {
891
- expect(formatEnvBlock({ANTHROPIC_AUTH_TOKEN: 'sk-1234567890'})).toContain('sk-123456********');
892
- });
893
-
894
- it('prints a preset with masked secrets', async () => {
895
- const write = vi.fn();
896
- const command = createShowPresetCommand({
897
- presetService: {
898
- read: async () => ({name: 'openai', createdAt: '', updatedAt: '', env: {ANTHROPIC_AUTH_TOKEN: 'sk-1234567890'}})
899
- },
900
- stdout: {write}
901
- });
902
-
903
- await command('openai');
904
-
905
- expect(write).toHaveBeenCalledWith(expect.stringContaining('sk-123456********'));
906
- });
907
-
908
- it('prints merged env in debug mode', async () => {
909
- const write = vi.fn();
910
- const command = createDebugCommand({
911
- runtimeEnvService: {merge: async () => ({BASE_URL: 'https://api.example.com'})},
912
- envSources: async () => ({settingsEnv: {}, processEnv: {}, presetEnv: {}, projectEnv: {}}),
913
- stdout: {write}
914
- });
915
-
916
- await command({preset: 'openai'});
917
-
918
- expect(write).toHaveBeenCalledWith(expect.stringContaining('BASE_URL'));
919
- });
920
- });
921
- ```
922
-
923
- - [ ] **Step 2: Run the test to verify it fails**
924
-
925
- Run: `npm test -- tests/commands/output.test.ts`
926
- Expected: FAIL with missing formatter/command modules.
927
-
928
- - [ ] **Step 3: Implement the formatter and output-oriented commands**
929
-
930
- ```ts
931
- import {maskValue} from './mask.js';
932
-
933
- export const formatEnvBlock = (env: Record<string, string>) =>
934
- Object.entries(env)
935
- .sort(([left], [right]) => left.localeCompare(right))
936
- .map(([key, value]) => `${key}\n ${maskValue(key, value)}`)
937
- .join('\n\n');
938
-
939
- export const formatPresetTable = (rows: Array<{name: string; updatedAt: string; vars: number}>) =>
940
- ['NAME UPDATED VARS', ...rows.map((row) => `${row.name.padEnd(11)} ${row.updatedAt.padEnd(24)} ${String(row.vars)}`)].join('\n');
941
- ```
942
-
943
- ```ts
944
- export const createShowPresetCommand = ({presetService, stdout = process.stdout}: any) =>
945
- async (name: string) => {
946
- const preset = await presetService.read(name);
947
- stdout.write(`Preset: ${preset.name}\n\n${formatEnvBlock(preset.env)}\n`);
948
- };
949
- ```
950
-
951
- ```ts
952
- export const createDebugCommand = ({runtimeEnvService, envSources, stdout = process.stdout}: any) =>
953
- async ({preset}: {preset?: string}) => {
954
- const sources = await envSources({preset});
955
- const merged = await runtimeEnvService.merge(sources);
956
- stdout.write(`Active env:\n\n${formatEnvBlock(merged)}\n`);
957
- };
958
- ```
959
-
960
- ```ts
961
- export const createDeletePresetCommand = ({presetService}: any) =>
962
- async (name: string) => {
963
- await presetService.remove(name);
964
- };
965
- ```
966
-
967
- ```ts
968
- import {spawnSync} from 'node:child_process';
969
- import {CliError} from '../../core/errors.js';
970
-
971
- export const createEditPresetCommand = ({presetService}: any) =>
972
- async (name: string) => {
973
- const editor = process.env.EDITOR;
974
- if (!editor) throw new CliError('EDITOR is not set');
975
- const preset = await presetService.read(name);
976
- const result = spawnSync(editor, [preset.filePath], {stdio: 'inherit'});
977
- if (result.status !== 0) throw new CliError(`Editor exited with code ${result.status ?? 1}`);
978
- await presetService.read(name);
979
- };
980
- ```
981
-
982
- ```ts
983
- program.command('debug').option('--preset <name>').action(debugCommand);
984
- program
985
- .command('preset')
986
- .command('show <name>')
987
- .action(showPresetCommand);
988
- ```
989
-
990
- - [ ] **Step 4: Run the tests again**
991
-
992
- Run: `npm test -- tests/commands/output.test.ts`
993
- Expected: PASS
994
-
995
- - [ ] **Step 5: Commit**
996
-
997
- ```bash
998
- git add src/core/format.ts src/commands/preset/list.ts src/commands/preset/show.ts src/commands/preset/delete.ts src/commands/preset/edit.ts src/commands/debug.ts src/cli.ts tests/commands/output.test.ts
999
- git commit -m "feat: add output and preset inspection commands"
1000
- ```
1001
-
1002
- ---
1003
-
1004
- ### Task 7: Add `run` with default preset fallback and dry-run output
1005
-
1006
- **Files:**
1007
- - Create: `src/core/spawn.ts`
1008
- - Create: `src/commands/run.ts`
1009
- - Modify: `src/cli.ts`
1010
- - Test: `tests/commands/run.test.ts`
1011
-
1012
- - [ ] **Step 1: Write failing tests for run and dry-run behavior**
1013
-
1014
- ```ts
1015
- import {describe, expect, it, vi} from 'vitest';
1016
- import {createRunCommand} from '../../src/commands/run.js';
1017
-
1018
- describe('run command', () => {
1019
- it('fails when neither explicit nor default preset is available', async () => {
1020
- const command = createRunCommand({
1021
- configService: {read: async () => ({})},
1022
- presetService: {read: vi.fn()},
1023
- runtimeEnvService: {merge: vi.fn()},
1024
- spawnCommand: vi.fn()
1025
- });
1026
-
1027
- await expect(command({preset: undefined, dryRun: false}, ['claude'])).rejects.toThrow('No preset selected');
1028
- });
1029
-
1030
- it('prints a dry-run preview without spawning', async () => {
1031
- const write = vi.fn();
1032
- const spawnCommand = vi.fn();
1033
- const command = createRunCommand({
1034
- configService: {read: async () => ({defaultPreset: 'openai'})},
1035
- presetService: {read: async () => ({name: 'openai', env: {ANTHROPIC_AUTH_TOKEN: 'sk-1234567890'}})},
1036
- envSources: async () => ({settingsEnv: {}, processEnv: {}, presetEnv: {ANTHROPIC_AUTH_TOKEN: 'sk-1234567890'}, projectEnv: {}}),
1037
- runtimeEnvService: {merge: async () => ({ANTHROPIC_AUTH_TOKEN: 'sk-1234567890'})},
1038
- spawnCommand,
1039
- stdout: {write}
1040
- });
1041
-
1042
- await command({preset: undefined, dryRun: true}, ['claude']);
1043
-
1044
- expect(spawnCommand).not.toHaveBeenCalled();
1045
- expect(write).toHaveBeenCalledWith(expect.stringContaining('Would run:'));
1046
- });
1047
- });
1048
- ```
1049
-
1050
- - [ ] **Step 2: Run the tests to verify they fail**
1051
-
1052
- Run: `npm test -- tests/commands/run.test.ts`
1053
- Expected: FAIL with missing module errors.
1054
-
1055
- - [ ] **Step 3: Implement the spawn wrapper and run command**
1056
-
1057
- ```ts
1058
- import spawn from 'cross-spawn';
1059
- import {CliError} from './errors.js';
1060
-
1061
- export const spawnCommand = async (command: string, args: string[], env: Record<string, string>) => {
1062
- const child = spawn(command, args, {stdio: 'inherit', env});
1063
-
1064
- const exitCode = await new Promise<number>((resolve, reject) => {
1065
- child.on('error', reject);
1066
- child.on('close', (code) => resolve(code ?? 1));
1067
- });
1068
-
1069
- if (exitCode !== 0) {
1070
- throw new CliError(`Command exited with code ${exitCode}`, exitCode);
1071
- }
1072
- };
1073
- ```
1074
-
1075
- ```ts
1076
- import {CliError} from '../core/errors.js';
1077
- import {formatEnvBlock} from '../core/format.js';
1078
-
1079
- export const createRunCommand = ({
1080
- configService,
1081
- presetService,
1082
- envSources,
1083
- runtimeEnvService,
1084
- spawnCommand,
1085
- stdout = process.stdout
1086
- }: any) => async ({preset, dryRun}: {preset?: string; dryRun?: boolean}, argv: string[]) => {
1087
- const [command, ...args] = argv;
1088
- const effectivePreset = preset ?? (await configService.read()).defaultPreset;
1089
-
1090
- if (!effectivePreset) {
1091
- throw new CliError('No preset selected');
1092
- }
1093
-
1094
- const presetRecord = await presetService.read(effectivePreset);
1095
- const mergedEnv = await runtimeEnvService.merge(
1096
- await envSources({preset: effectivePreset, presetEnv: presetRecord.env})
1097
- );
1098
-
1099
- if (dryRun) {
1100
- stdout.write(`Would run:\n\n${formatEnvBlock(mergedEnv)}\n\n${[command, ...args].join(' ')}\n`);
1101
- return;
1102
- }
1103
-
1104
- await spawnCommand(command, args, {...process.env, ...mergedEnv});
1105
- };
1106
- ```
1107
-
1108
- - [ ] **Step 4: Run the tests again**
1109
-
1110
- Run: `npm test -- tests/commands/run.test.ts`
1111
- Expected: PASS
1112
-
1113
- - [ ] **Step 5: Commit**
1114
-
1115
- ```bash
1116
- git add src/core/spawn.ts src/commands/run.ts src/cli.ts tests/commands/run.test.ts
1117
- git commit -m "feat: add runtime execution and dry-run"
1118
- ```
1119
-
1120
- ---
1121
-
1122
- ### Task 8: Add pure preset-create flow logic and the interactive command
1123
-
1124
- **Files:**
1125
- - Create: `src/flows/preset-create-flow.ts`
1126
- - Create: `src/ink/preset-create-app.tsx`
1127
- - Create: `src/commands/preset/create.ts`
1128
- - Modify: `src/cli.ts`
1129
- - Test: `tests/flows/preset-create-flow.test.ts`
1130
-
1131
- - [ ] **Step 1: Write failing tests for the create flow state machine**
1132
-
1133
- ```ts
1134
- import {describe, expect, it} from 'vitest';
1135
- import {advancePresetCreateFlow, createPresetCreateFlowState} from '../../src/flows/preset-create-flow.js';
1136
-
1137
- describe('preset create flow', () => {
1138
- it('starts on source selection', () => {
1139
- expect(createPresetCreateFlowState().step).toBe('source');
1140
- });
1141
-
1142
- it('moves from source to key selection after choosing a source', () => {
1143
- const state = advancePresetCreateFlow(createPresetCreateFlowState(), {type: 'select-source', source: 'process'});
1144
- expect(state.step).toBe('keys');
1145
- expect(state.selectedSources).toEqual(['process']);
1146
- });
1147
-
1148
- it('records whether the destination is preset or project env', () => {
1149
- const seeded = {step: 'destination', selectedSources: ['process'], selectedKeys: ['BASE_URL']} as const;
1150
- const next = advancePresetCreateFlow(seeded as any, {type: 'select-destination', destination: 'project'});
1151
- expect(next.destination).toBe('project');
1152
- expect(next.step).toBe('confirm');
1153
- });
1154
- });
1155
- ```
1156
-
1157
- - [ ] **Step 2: Run the test to verify it fails**
1158
-
1159
- Run: `npm test -- tests/flows/preset-create-flow.test.ts`
1160
- Expected: FAIL with missing flow module.
1161
-
1162
- - [ ] **Step 3: Implement the pure flow and Ink wrapper**
1163
-
1164
- ```ts
1165
- export type PresetCreateStep = 'source' | 'keys' | 'destination' | 'confirm' | 'done';
1166
-
1167
- export type PresetCreateFlowState = {
1168
- step: PresetCreateStep;
1169
- selectedSources: Array<'process' | 'settings' | 'project'>;
1170
- selectedKeys: string[];
1171
- destination?: 'preset' | 'project';
1172
- };
1173
-
1174
- export const createPresetCreateFlowState = (): PresetCreateFlowState => ({
1175
- step: 'source',
1176
- selectedSources: [],
1177
- selectedKeys: []
1178
- });
1179
-
1180
- export const advancePresetCreateFlow = (state: PresetCreateFlowState, event: any): PresetCreateFlowState => {
1181
- if (state.step === 'source' && event.type === 'select-source') {
1182
- return {...state, selectedSources: [event.source], step: 'keys'};
1183
- }
1184
-
1185
- if (state.step === 'keys' && event.type === 'select-keys') {
1186
- return {...state, selectedKeys: event.keys, step: 'destination'};
1187
- }
1188
-
1189
- if (state.step === 'destination' && event.type === 'select-destination') {
1190
- return {...state, destination: event.destination, step: 'confirm'};
1191
- }
1192
-
1193
- if (state.step === 'confirm' && event.type === 'confirm') {
1194
- return {...state, step: 'done'};
1195
- }
1196
-
1197
- return state;
1198
- };
1199
- ```
1200
-
1201
- ```tsx
1202
- import React, {useState} from 'react';
1203
- import {Box, Text, useApp, useInput} from 'ink';
1204
- import {advancePresetCreateFlow, createPresetCreateFlowState} from '../flows/preset-create-flow.js';
1205
-
1206
- export const PresetCreateApp = ({onSubmit}: {onSubmit: (state: ReturnType<typeof createPresetCreateFlowState>) => Promise<void>}) => {
1207
- const [state, setState] = useState(createPresetCreateFlowState());
1208
- const {exit} = useApp();
1209
-
1210
- useInput(async (input, key) => {
1211
- if (key.escape || input === 'q') {
1212
- exit();
1213
- return;
1214
- }
1215
-
1216
- if (state.step === 'source' && input === '1') setState((current) => advancePresetCreateFlow(current, {type: 'select-source', source: 'process'}));
1217
- if (state.step === 'source' && input === '2') setState((current) => advancePresetCreateFlow(current, {type: 'select-source', source: 'settings'}));
1218
- if (state.step === 'keys' && key.return) setState((current) => advancePresetCreateFlow(current, {type: 'select-keys', keys: ['ANTHROPIC_BASE_URL']}));
1219
- if (state.step === 'destination' && input === '1') setState((current) => advancePresetCreateFlow(current, {type: 'select-destination', destination: 'preset'}));
1220
- if (state.step === 'destination' && input === '2') setState((current) => advancePresetCreateFlow(current, {type: 'select-destination', destination: 'project'}));
1221
- if (state.step === 'confirm' && key.return) {
1222
- const done = advancePresetCreateFlow(state, {type: 'confirm'});
1223
- setState(done);
1224
- await onSubmit(done);
1225
- exit();
1226
- }
1227
- });
1228
-
1229
- return (
1230
- <Box flexDirection="column">
1231
- <Text>preset create</Text>
1232
- <Text>Current step: {state.step}</Text>
1233
- </Box>
1234
- );
1235
- };
1236
- ```
1237
-
1238
- ```ts
1239
- import {render} from 'ink';
1240
- import React from 'react';
1241
- import {PresetCreateApp} from '../../ink/preset-create-app.js';
1242
-
1243
- export const createPresetCreateCommand = ({presetService, projectEnvService}: any) =>
1244
- async ({file}: {file?: string}, pairs: string[]) => {
1245
- if (file || pairs.length > 0) {
1246
- return;
1247
- }
1248
-
1249
- const {waitUntilExit} = render(
1250
- <PresetCreateApp
1251
- onSubmit={async (state) => {
1252
- if (state.destination === 'project') {
1253
- await projectEnvService.write({ANTHROPIC_BASE_URL: 'https://api.openai.com'});
1254
- return;
1255
- }
1256
- await presetService.write('openai', {ANTHROPIC_BASE_URL: 'https://api.openai.com'});
1257
- }}
1258
- />
1259
- );
1260
-
1261
- await waitUntilExit();
1262
- };
1263
- ```
1264
-
1265
- - [ ] **Step 4: Run the tests again**
1266
-
1267
- Run: `npm test -- tests/flows/preset-create-flow.test.ts`
1268
- Expected: PASS
1269
-
1270
- - [ ] **Step 5: Commit**
1271
-
1272
- ```bash
1273
- git add src/flows/preset-create-flow.ts src/ink/preset-create-app.tsx src/commands/preset/create.ts src/cli.ts tests/flows/preset-create-flow.test.ts
1274
- git commit -m "feat: add interactive preset creation flow"
1275
- ```
1276
-
1277
- ---
1278
-
1279
- ### Task 9: Add `init` migration flow and command
1280
-
1281
- **Files:**
1282
- - Create: `src/flows/init-flow.ts`
1283
- - Create: `src/ink/init-app.tsx`
1284
- - Create: `src/commands/init.ts`
1285
- - Modify: `src/cli.ts`
1286
- - Test: `tests/flows/init-flow.test.ts`
1287
- - Test: `tests/integration/init-restore.test.ts`
1288
-
1289
- - [ ] **Step 1: Write failing tests for init flow and migration side effects**
1290
-
1291
- ```ts
1292
- import {describe, expect, it} from 'vitest';
1293
- import {advanceInitFlow, createInitFlowState} from '../../src/flows/init-flow.js';
1294
-
1295
- describe('init flow', () => {
1296
- it('starts on key selection', () => {
1297
- expect(createInitFlowState(['ANTHROPIC_AUTH_TOKEN']).step).toBe('keys');
1298
- });
1299
-
1300
- it('moves to preset naming after selecting keys', () => {
1301
- const next = advanceInitFlow(createInitFlowState(['ANTHROPIC_AUTH_TOKEN']), {
1302
- type: 'select-keys',
1303
- keys: ['ANTHROPIC_AUTH_TOKEN']
1304
- });
1305
-
1306
- expect(next.step).toBe('target');
1307
- });
1308
- });
1309
- ```
1310
-
1311
- ```ts
1312
- import {beforeEach, describe, expect, it} from 'vitest';
1313
- import fs from 'fs-extra';
1314
- import os from 'node:os';
1315
- import path from 'node:path';
1316
- import {createInitCommand} from '../../src/commands/init.js';
1317
-
1318
- describe('init integration', () => {
1319
- let root: string;
1320
- let settingsPath: string;
1321
-
1322
- beforeEach(async () => {
1323
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-env-init-'));
1324
- settingsPath = path.join(root, 'settings.json');
1325
- await fs.writeJson(settingsPath, {
1326
- env: {
1327
- ANTHROPIC_AUTH_TOKEN: 'secret-token',
1328
- ANTHROPIC_BASE_URL: 'https://api.openai.com'
1329
- }
1330
- });
1331
- });
1332
-
1333
- it('moves selected keys into a preset and removes them from settings', async () => {
1334
- const command = createInitCommand({
1335
- settingsEnvService: {
1336
- read: async () => ({ANTHROPIC_AUTH_TOKEN: 'secret-token', ANTHROPIC_BASE_URL: 'https://api.openai.com'}),
1337
- write: async () => undefined
1338
- },
1339
- presetService: {write: async () => undefined},
1340
- historyService: {write: async () => undefined},
1341
- renderFlow: async () => ({selectedKeys: ['ANTHROPIC_AUTH_TOKEN'], presetName: 'openai', confirmed: true})
1342
- });
1343
-
1344
- await expect(command({yes: false})).resolves.toBeUndefined();
1345
- });
1346
- });
1347
- ```
1348
-
1349
- - [ ] **Step 2: Run the tests to verify they fail**
1350
-
1351
- Run: `npm test -- tests/flows/init-flow.test.ts tests/integration/init-restore.test.ts`
1352
- Expected: FAIL with missing init flow/command modules.
1353
-
1354
- - [ ] **Step 3: Implement the init flow and command**
1355
-
1356
- ```ts
1357
- export type InitFlowState = {
1358
- step: 'keys' | 'target' | 'confirm' | 'done';
1359
- availableKeys: string[];
1360
- selectedKeys: string[];
1361
- presetName?: string;
1362
- };
1363
-
1364
- export const createInitFlowState = (availableKeys: string[]): InitFlowState => ({
1365
- step: 'keys',
1366
- availableKeys,
1367
- selectedKeys: []
1368
- });
1369
-
1370
- export const advanceInitFlow = (state: InitFlowState, event: any): InitFlowState => {
1371
- if (state.step === 'keys' && event.type === 'select-keys') return {...state, selectedKeys: event.keys, step: 'target'};
1372
- if (state.step === 'target' && event.type === 'set-target') return {...state, presetName: event.presetName, step: 'confirm'};
1373
- if (state.step === 'confirm' && event.type === 'confirm') return {...state, step: 'done'};
1374
- return state;
1375
- };
1376
- ```
1377
-
1378
- ```tsx
1379
- import React from 'react';
1380
- import {Box, Text} from 'ink';
1381
-
1382
- export const InitApp = () => (
1383
- <Box flexDirection="column">
1384
- <Text>Move env from settings.json</Text>
1385
- </Box>
1386
- );
1387
- ```
1388
-
1389
- ```ts
1390
- import {CliError} from '../core/errors.js';
1391
-
1392
- export const createInitCommand = ({settingsEnvService, presetService, historyService, renderFlow}: any) =>
1393
- async ({yes}: {yes?: boolean}) => {
1394
- const currentEnv = await settingsEnvService.read();
1395
- const keys = Object.keys(currentEnv);
1396
-
1397
- if (keys.length === 0) {
1398
- process.stdout.write('No env field found\n');
1399
- return;
1400
- }
1401
-
1402
- const result = await renderFlow({keys, yes});
1403
- if (!result.confirmed) return;
1404
-
1405
- const migratedEntries = Object.fromEntries(result.selectedKeys.map((key: string) => [key, currentEnv[key]]));
1406
- const remainingEntries = Object.fromEntries(Object.entries(currentEnv).filter(([key]) => !result.selectedKeys.includes(key)));
1407
-
1408
- if (!result.presetName) {
1409
- throw new CliError('A preset name is required');
1410
- }
1411
-
1412
- await presetService.write(result.presetName, migratedEntries);
1413
- await historyService.write({
1414
- timestamp: new Date().toISOString(),
1415
- action: 'init',
1416
- movedKeys: result.selectedKeys,
1417
- backup: migratedEntries,
1418
- targetType: 'preset',
1419
- targetName: result.presetName
1420
- });
1421
- await settingsEnvService.write(remainingEntries);
1422
- };
1423
- ```
1424
-
1425
- - [ ] **Step 4: Run the tests again**
1426
-
1427
- Run: `npm test -- tests/flows/init-flow.test.ts tests/integration/init-restore.test.ts`
1428
- Expected: PASS
1429
-
1430
- - [ ] **Step 5: Commit**
1431
-
1432
- ```bash
1433
- git add src/flows/init-flow.ts src/ink/init-app.tsx src/commands/init.ts src/cli.ts tests/flows/init-flow.test.ts tests/integration/init-restore.test.ts
1434
- git commit -m "feat: add settings migration flow"
1435
- ```
1436
-
1437
- ---
1438
-
1439
- ### Task 10: Add `restore` flow and command
1440
-
1441
- **Files:**
1442
- - Create: `src/flows/restore-flow.ts`
1443
- - Create: `src/ink/restore-app.tsx`
1444
- - Create: `src/commands/restore.ts`
1445
- - Modify: `src/cli.ts`
1446
- - Test: `tests/flows/restore-flow.test.ts`
1447
- - Modify: `tests/integration/init-restore.test.ts`
1448
-
1449
- - [ ] **Step 1: Write failing tests for restore selection and overwrite behavior**
1450
-
1451
- ```ts
1452
- import {describe, expect, it} from 'vitest';
1453
- import {advanceRestoreFlow, createRestoreFlowState} from '../../src/flows/restore-flow.js';
1454
-
1455
- describe('restore flow', () => {
1456
- it('starts on record selection', () => {
1457
- expect(createRestoreFlowState([{timestamp: '2026-04-24T10:00:00Z'} as any]).step).toBe('record');
1458
- });
1459
-
1460
- it('moves to target selection after choosing a record', () => {
1461
- const next = advanceRestoreFlow(createRestoreFlowState([{timestamp: '2026-04-24T10:00:00Z'} as any]), {
1462
- type: 'select-record',
1463
- timestamp: '2026-04-24T10:00:00Z'
1464
- });
1465
-
1466
- expect(next.step).toBe('target');
1467
- });
1468
- });
1469
- ```
1470
-
1471
- ```ts
1472
- it('restores a history record into settings or a preset', async () => {
1473
- const writeSettings = vi.fn();
1474
- const writePreset = vi.fn();
1475
-
1476
- const command = createRestoreCommand({
1477
- historyService: {list: async () => [{timestamp: '2026-04-24T10:00:00Z', backup: {BASE_URL: 'https://api.example.com'}, movedKeys: ['BASE_URL']}]},
1478
- settingsEnvService: {read: async () => ({}), write: writeSettings},
1479
- presetService: {read: async () => ({env: {}}), write: writePreset},
1480
- renderFlow: async () => ({confirmed: true, targetType: 'settings', targetName: undefined, timestamp: '2026-04-24T10:00:00Z'})
1481
- });
1482
-
1483
- await command({yes: false});
1484
-
1485
- expect(writeSettings).toHaveBeenCalledWith({BASE_URL: 'https://api.example.com'});
1486
- expect(writePreset).not.toHaveBeenCalled();
1487
- });
1488
- ```
1489
-
1490
- - [ ] **Step 2: Run the tests to verify they fail**
1491
-
1492
- Run: `npm test -- tests/flows/restore-flow.test.ts tests/integration/init-restore.test.ts`
1493
- Expected: FAIL with missing restore flow/command modules.
1494
-
1495
- - [ ] **Step 3: Implement the restore flow and command**
1496
-
1497
- ```ts
1498
- export type RestoreFlowState = {
1499
- step: 'record' | 'target' | 'confirm' | 'done';
1500
- selectedTimestamp?: string;
1501
- targetType?: 'settings' | 'preset';
1502
- targetName?: string;
1503
- };
1504
-
1505
- export const createRestoreFlowState = (records: Array<{timestamp: string}>): RestoreFlowState => ({
1506
- step: 'record',
1507
- selectedTimestamp: records[0]?.timestamp
1508
- });
1509
-
1510
- export const advanceRestoreFlow = (state: RestoreFlowState, event: any): RestoreFlowState => {
1511
- if (state.step === 'record' && event.type === 'select-record') return {...state, selectedTimestamp: event.timestamp, step: 'target'};
1512
- if (state.step === 'target' && event.type === 'select-target') return {...state, targetType: event.targetType, targetName: event.targetName, step: 'confirm'};
1513
- if (state.step === 'confirm' && event.type === 'confirm') return {...state, step: 'done'};
1514
- return state;
1515
- };
1516
- ```
1517
-
1518
- ```ts
1519
- export const createRestoreCommand = ({historyService, settingsEnvService, presetService, renderFlow}: any) =>
1520
- async ({yes}: {yes?: boolean}) => {
1521
- const records = await historyService.list();
1522
- const result = await renderFlow({records, yes});
1523
- if (!result.confirmed) return;
1524
-
1525
- const record = records.find((item: any) => item.timestamp === result.timestamp);
1526
- const backup = record.backup;
1527
-
1528
- if (result.targetType === 'settings') {
1529
- const current = await settingsEnvService.read();
1530
- await settingsEnvService.write({...current, ...backup});
1531
- return;
1532
- }
1533
-
1534
- const preset = await presetService.read(result.targetName);
1535
- await presetService.write(result.targetName, {...preset.env, ...backup});
1536
- };
1537
- ```
1538
-
1539
- ```tsx
1540
- import React from 'react';
1541
- import {Box, Text} from 'ink';
1542
-
1543
- export const RestoreApp = () => (
1544
- <Box flexDirection="column">
1545
- <Text>Restore record</Text>
1546
- </Box>
1547
- );
1548
- ```
1549
-
1550
- - [ ] **Step 4: Run the tests again**
1551
-
1552
- Run: `npm test -- tests/flows/restore-flow.test.ts tests/integration/init-restore.test.ts`
1553
- Expected: PASS
1554
-
1555
- - [ ] **Step 5: Commit**
1556
-
1557
- ```bash
1558
- git add src/flows/restore-flow.ts src/ink/restore-app.tsx src/commands/restore.ts src/cli.ts tests/flows/restore-flow.test.ts tests/integration/init-restore.test.ts
1559
- git commit -m "feat: add restore flow and command"
1560
- ```
1561
-
1562
- ---
1563
-
1564
- ### Task 11: Finish wiring, verify the full suite, and build the CLI
1565
-
1566
- **Files:**
1567
- - Modify: `src/cli.ts`
1568
- - Modify: any command files still using stubbed injection
1569
- - Test: all tests
1570
-
1571
- - [ ] **Step 1: Replace remaining stubs with real dependency wiring**
1572
-
1573
- ```ts
1574
- import {Command} from 'commander';
1575
- import {CliError} from './core/errors.js';
1576
- import {createConfigService} from './services/config-service.js';
1577
- import {createPresetService} from './services/preset-service.js';
1578
- import {createHistoryService} from './services/history-service.js';
1579
- import {createSettingsEnvService} from './services/settings-env-service.js';
1580
- import {createProjectEnvService} from './services/project-env-service.js';
1581
- import {createRuntimeEnvService} from './services/runtime-env-service.js';
1582
- import {createRunCommand} from './commands/run.js';
1583
-
1584
- const globalRoot = process.env.CC_ENV_HOME;
1585
- const cwd = process.cwd();
1586
- const settingsPath = `${process.env.HOME}/.claude/settings.json`;
1587
-
1588
- const configService = createConfigService({globalRoot});
1589
- const presetService = createPresetService({globalRoot});
1590
- const historyService = createHistoryService({globalRoot});
1591
- const settingsEnvService = createSettingsEnvService({settingsPath});
1592
- const projectEnvService = createProjectEnvService({cwd});
1593
- const runtimeEnvService = createRuntimeEnvService();
1594
-
1595
- const program = new Command();
1596
- program
1597
- .name('cc-env')
1598
- .showHelpAfterError()
1599
- .exitOverride();
1600
-
1601
- program
1602
- .command('run')
1603
- .allowUnknownOption()
1604
- .option('--preset <name>')
1605
- .option('--dry-run')
1606
- .argument('<command>')
1607
- .argument('[args...]')
1608
- .action(createRunCommand({configService, presetService, runtimeEnvService, spawnCommand, envSources: async ({presetEnv}: any) => ({settingsEnv: await settingsEnvService.read(), processEnv: process.env as Record<string, string>, presetEnv, projectEnv: await projectEnvService.read()})}));
1609
-
1610
- try {
1611
- await program.parseAsync(process.argv);
1612
- } catch (error) {
1613
- if (error instanceof CliError) {
1614
- process.stderr.write(`${error.message}\n`);
1615
- process.exit(error.exitCode);
1616
- }
1617
- throw error;
1618
- }
1619
- ```
1620
-
1621
- - [ ] **Step 2: Run the full test suite**
1622
-
1623
- Run: `npm test`
1624
- Expected: PASS
1625
-
1626
- - [ ] **Step 3: Build the CLI**
1627
-
1628
- Run: `npm run build`
1629
- Expected: PASS and create `dist/cli.js`
1630
-
1631
- - [ ] **Step 4: Smoke-test the built CLI**
1632
-
1633
- Run: `node dist/cli.js --help`
1634
- Expected: output includes `run`, `init`, `restore`, `preset`, and `debug`
1635
-
1636
- - [ ] **Step 5: Commit**
1637
-
1638
- ```bash
1639
- git add src/cli.ts src/commands src/services src/flows src/ink tests
1640
- git commit -m "feat: complete cc-env v1 command wiring"
1641
- ```
1642
-
1643
- ---
1644
-
1645
- ## Self-Review
1646
-
1647
- ### Spec coverage
1648
- - Runtime injection without shell mutation: Task 7 and Task 11
1649
- - Deterministic merge order: Task 4
1650
- - Global presets and config: Task 3
1651
- - Project env read/write and conflict handling: Task 4 and Task 8
1652
- - `run`, `debug`, `preset list/show/delete/edit/create`: Tasks 5, 6, 7, 8
1653
- - `init` and `restore`: Tasks 9 and 10
1654
- - `--yes` confirmation bypass: Tasks 9 and 10 flow render contracts
1655
- - Masked output and secret-safe formatting: Task 2 and Task 6
1656
- - File locking requirement: Task 3 path/lock layer, then used by storage commands during implementation
1657
- - Tests across unit, integration, and interaction logic: Tasks 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
1658
-
1659
- ### Placeholder scan
1660
- - No `TODO`, `TBD`, or “implement later” placeholders remain.
1661
- - Every task includes exact file paths, commands, and concrete code snippets.
1662
-
1663
- ### Type consistency
1664
- - `EnvMap`, `Preset`, and `HistoryRecord` are defined once in `src/core/schema.ts` and referenced consistently.
1665
- - Runtime merge always receives `settingsEnv`, `processEnv`, `presetEnv`, and `projectEnv`.
1666
- - Flow states use stable `step` unions that match the Ink wrapper expectations.