@lkangd/cc-env 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/dist/cli.js +68 -6
- package/dist/commands/completion.js +60 -0
- package/dist/commands/doctor.js +73 -0
- package/dist/commands/preset/edit.js +16 -11
- package/dist/commands/preset/rename.js +16 -0
- package/dist/commands/run.js +9 -1
- package/dist/ink/preset-edit-app.js +112 -0
- package/package.json +11 -2
- package/.claude/settings.json +0 -6
- package/.claude/settings.local.json +0 -8
- package/.nvmrc +0 -1
- package/CHANGELOG.md +0 -71
- package/docs/product-specs/index.draft.md +0 -106
- package/docs/product-specs/index.md +0 -911
- package/docs/product-specs/optional.md +0 -42
- package/docs/references/claude-code-env.md +0 -224
- package/docs/superpowers/plans/2026-04-24-cc-env-init-shell-migration.md +0 -1331
- package/docs/superpowers/plans/2026-04-24-cc-env.md +0 -1666
- package/docs/superpowers/plans/2026-04-26-preset-create-interactive-refactor.md +0 -1432
- package/docs/superpowers/specs/2026-04-24-cc-env-design.md +0 -438
- package/docs/superpowers/specs/2026-04-24-cc-env-init-shell-migration-design.md +0 -181
- package/docs/superpowers/specs/2026-04-26-preset-create-interactive-refactor-design.md +0 -78
- package/src/cli.ts +0 -340
- package/src/commands/init.ts +0 -139
- package/src/commands/preset/create.ts +0 -96
- package/src/commands/preset/delete.ts +0 -62
- package/src/commands/preset/show.ts +0 -51
- package/src/commands/restore.ts +0 -150
- package/src/commands/run.ts +0 -158
- package/src/core/errors.ts +0 -13
- package/src/core/find-claude.ts +0 -70
- package/src/core/format.ts +0 -29
- package/src/core/fs.ts +0 -18
- package/src/core/gitignore.ts +0 -26
- package/src/core/logger.ts +0 -11
- package/src/core/mask.ts +0 -17
- package/src/core/paths.ts +0 -41
- package/src/core/process-env.ts +0 -11
- package/src/core/schema.ts +0 -55
- package/src/core/spawn.ts +0 -36
- package/src/flows/init-flow.ts +0 -61
- package/src/flows/preset-create-flow.ts +0 -129
- package/src/flows/restore-flow.ts +0 -144
- package/src/ink/init-app.tsx +0 -110
- package/src/ink/preset-create-app.tsx +0 -451
- package/src/ink/preset-delete-app.tsx +0 -114
- package/src/ink/preset-show-app.tsx +0 -76
- package/src/ink/restore-app.tsx +0 -230
- package/src/ink/run-preset-select-app.tsx +0 -83
- package/src/ink/summary.tsx +0 -91
- package/src/services/claude-settings-env-service.ts +0 -72
- package/src/services/history-service.ts +0 -48
- package/src/services/preset-service.ts +0 -72
- package/src/services/project-env-service.ts +0 -128
- package/src/services/project-state-service.ts +0 -31
- package/src/services/settings-env-service.ts +0 -40
- package/src/services/shell-env-service.ts +0 -112
- package/src/types.d.ts +0 -19
- package/tests/cli/help.test.ts +0 -133
- package/tests/cli/init.test.ts +0 -76
- package/tests/cli/restore.test.ts +0 -172
- package/tests/commands/create.test.ts +0 -263
- package/tests/commands/output.test.ts +0 -119
- package/tests/commands/run.test.ts +0 -218
- package/tests/core/gitignore.test.ts +0 -98
- package/tests/core/paths.test.ts +0 -24
- package/tests/core/schema-mask.test.ts +0 -182
- package/tests/core/spawn.test.ts +0 -47
- package/tests/flows/init-flow.test.ts +0 -40
- package/tests/flows/preset-create-flow.test.ts +0 -225
- package/tests/flows/restore-flow.test.ts +0 -157
- package/tests/integration/init-restore.test.ts +0 -406
- package/tests/services/claude-shell.test.ts +0 -183
- package/tests/services/storage.test.ts +0 -143
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -22
- 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.
|