@lkangd/cc-env 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude/settings.json +6 -0
  2. package/.claude/settings.local.json +3 -0
  3. package/.nvmrc +1 -0
  4. package/dist/cli.js +266 -0
  5. package/dist/commands/debug.js +17 -0
  6. package/dist/commands/init.js +64 -0
  7. package/dist/commands/preset/create.js +61 -0
  8. package/dist/commands/preset/delete.js +25 -0
  9. package/dist/commands/preset/edit.js +15 -0
  10. package/dist/commands/preset/list.js +16 -0
  11. package/dist/commands/preset/show.js +16 -0
  12. package/dist/commands/restore.js +65 -0
  13. package/dist/commands/run.js +80 -0
  14. package/dist/core/errors.js +11 -0
  15. package/dist/core/find-claude.js +64 -0
  16. package/dist/core/format.js +23 -0
  17. package/dist/core/fs.js +12 -0
  18. package/dist/core/gitignore.js +23 -0
  19. package/dist/core/lock.js +25 -0
  20. package/dist/core/logger.js +8 -0
  21. package/dist/core/mask.js +13 -0
  22. package/dist/core/paths.js +32 -0
  23. package/dist/core/process-env.js +4 -0
  24. package/dist/core/schema.js +38 -0
  25. package/dist/core/spawn.js +26 -0
  26. package/dist/flows/init-flow.js +35 -0
  27. package/dist/flows/preset-create-flow.js +80 -0
  28. package/dist/flows/restore-flow.js +75 -0
  29. package/dist/ink/init-app.js +54 -0
  30. package/dist/ink/preset-create-app.js +271 -0
  31. package/dist/ink/preset-delete-app.js +47 -0
  32. package/dist/ink/preset-list-app.js +27 -0
  33. package/dist/ink/preset-show-app.js +27 -0
  34. package/dist/ink/restore-app.js +102 -0
  35. package/dist/ink/run-preset-select-app.js +31 -0
  36. package/dist/ink/summary.js +28 -0
  37. package/dist/services/claude-settings-env-service.js +55 -0
  38. package/dist/services/config-service.js +26 -0
  39. package/dist/services/history-service.js +39 -0
  40. package/dist/services/preset-service.js +61 -0
  41. package/dist/services/project-env-service.js +90 -0
  42. package/dist/services/project-state-service.js +26 -0
  43. package/dist/services/runtime-env-service.js +13 -0
  44. package/dist/services/settings-env-service.js +36 -0
  45. package/dist/services/shell-env-service.js +77 -0
  46. package/docs/product-specs/index.draft.md +106 -0
  47. package/docs/product-specs/index.md +911 -0
  48. package/docs/product-specs/optional.md +42 -0
  49. package/docs/references/claude-code-env.md +224 -0
  50. package/docs/superpowers/plans/2026-04-24-cc-env-init-shell-migration.md +1331 -0
  51. package/docs/superpowers/plans/2026-04-24-cc-env.md +1666 -0
  52. package/docs/superpowers/plans/2026-04-26-preset-create-interactive-refactor.md +1432 -0
  53. package/docs/superpowers/specs/2026-04-24-cc-env-design.md +438 -0
  54. package/docs/superpowers/specs/2026-04-24-cc-env-init-shell-migration-design.md +181 -0
  55. package/docs/superpowers/specs/2026-04-26-preset-create-interactive-refactor-design.md +78 -0
  56. package/package.json +55 -0
  57. package/src/cli.ts +337 -0
  58. package/src/commands/init.ts +139 -0
  59. package/src/commands/preset/create.ts +96 -0
  60. package/src/commands/preset/delete.ts +62 -0
  61. package/src/commands/preset/show.ts +51 -0
  62. package/src/commands/restore.ts +150 -0
  63. package/src/commands/run.ts +158 -0
  64. package/src/core/errors.ts +13 -0
  65. package/src/core/find-claude.ts +70 -0
  66. package/src/core/format.ts +29 -0
  67. package/src/core/fs.ts +18 -0
  68. package/src/core/gitignore.ts +26 -0
  69. package/src/core/logger.ts +11 -0
  70. package/src/core/mask.ts +17 -0
  71. package/src/core/paths.ts +41 -0
  72. package/src/core/process-env.ts +11 -0
  73. package/src/core/schema.ts +55 -0
  74. package/src/core/spawn.ts +36 -0
  75. package/src/flows/init-flow.ts +61 -0
  76. package/src/flows/preset-create-flow.ts +129 -0
  77. package/src/flows/restore-flow.ts +144 -0
  78. package/src/ink/init-app.tsx +110 -0
  79. package/src/ink/preset-create-app.tsx +451 -0
  80. package/src/ink/preset-delete-app.tsx +114 -0
  81. package/src/ink/preset-show-app.tsx +76 -0
  82. package/src/ink/restore-app.tsx +230 -0
  83. package/src/ink/run-preset-select-app.tsx +83 -0
  84. package/src/ink/summary.tsx +91 -0
  85. package/src/services/claude-settings-env-service.ts +72 -0
  86. package/src/services/history-service.ts +48 -0
  87. package/src/services/preset-service.ts +72 -0
  88. package/src/services/project-env-service.ts +128 -0
  89. package/src/services/project-state-service.ts +31 -0
  90. package/src/services/settings-env-service.ts +40 -0
  91. package/src/services/shell-env-service.ts +112 -0
  92. package/src/types.d.ts +19 -0
  93. package/tests/cli/help.test.ts +133 -0
  94. package/tests/cli/init.test.ts +76 -0
  95. package/tests/cli/restore.test.ts +172 -0
  96. package/tests/commands/create.test.ts +263 -0
  97. package/tests/commands/output.test.ts +119 -0
  98. package/tests/commands/run.test.ts +218 -0
  99. package/tests/core/gitignore.test.ts +98 -0
  100. package/tests/core/paths.test.ts +24 -0
  101. package/tests/core/schema-mask.test.ts +182 -0
  102. package/tests/core/spawn.test.ts +47 -0
  103. package/tests/flows/init-flow.test.ts +40 -0
  104. package/tests/flows/preset-create-flow.test.ts +225 -0
  105. package/tests/flows/restore-flow.test.ts +157 -0
  106. package/tests/integration/init-restore.test.ts +406 -0
  107. package/tests/services/claude-shell.test.ts +183 -0
  108. package/tests/services/storage.test.ts +143 -0
  109. package/tsconfig.build.json +9 -0
  110. package/tsconfig.json +22 -0
  111. package/vitest.config.ts +8 -0
@@ -0,0 +1,230 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { Box, Text, useApp, useInput } from 'ink'
3
+
4
+ import { advanceRestoreFlow, type RestoreFlowState } from '../flows/restore-flow.js'
5
+ import { EnvEntries, EnvSummary } from './summary.js'
6
+
7
+ export function RestoreApp({
8
+ state,
9
+ onSubmit,
10
+ }: {
11
+ state: RestoreFlowState
12
+ onSubmit?: (result: {
13
+ confirmed: boolean
14
+ timestamp?: string
15
+ targetType?: 'settings' | 'preset'
16
+ targetName?: string
17
+ }) => void
18
+ }) {
19
+ const { exit } = useApp()
20
+ const [currentState, setCurrentState] = useState(state)
21
+ const [cursor, setCursor] = useState(0)
22
+ const recordAtCursor = currentState.records[cursor]
23
+ const selectedRecord = currentState.records.find(
24
+ (record) => record.timestamp === currentState.selectedTimestamp,
25
+ )
26
+ const activeRecord = currentState.step === 'record'
27
+ ? recordAtCursor
28
+ : selectedRecord ?? currentState.records[0]
29
+ const restoreEntries = useMemo(
30
+ () =>
31
+ activeRecord
32
+ ? Object.entries(
33
+ activeRecord.action === 'init'
34
+ ? Object.fromEntries(activeRecord.sources.flatMap((s) => Object.entries(s.backup)))
35
+ : activeRecord.backup,
36
+ ).sort(([left], [right]) => left.localeCompare(right)) as [string, string][]
37
+ : [],
38
+ [activeRecord],
39
+ )
40
+
41
+ const fromFiles = useMemo(() => {
42
+ if (!activeRecord || activeRecord.action !== 'init') {
43
+ return []
44
+ }
45
+
46
+ return activeRecord.shellWrites.map((sw) => sw.filePath)
47
+ }, [activeRecord])
48
+
49
+ const toFiles = useMemo(() => {
50
+ if (!activeRecord || activeRecord.action !== 'init') {
51
+ return []
52
+ }
53
+
54
+ return activeRecord.sources.map((s) => s.file)
55
+ }, [activeRecord])
56
+
57
+ useEffect(() => {
58
+ setCurrentState(state)
59
+ setCursor(0)
60
+ }, [state])
61
+
62
+ useEffect(() => {
63
+ if (!onSubmit || currentState.records.length !== 0) {
64
+ return
65
+ }
66
+
67
+ onSubmit({
68
+ confirmed: false,
69
+ })
70
+ exit()
71
+ }, [currentState.records.length, exit, onSubmit])
72
+
73
+ useInput((input, key) => {
74
+ if (!onSubmit) {
75
+ return
76
+ }
77
+
78
+ if (key.escape || input.toLowerCase() === 'q') {
79
+ onSubmit({ confirmed: false })
80
+ exit()
81
+ return
82
+ }
83
+
84
+ if (currentState.step === 'record') {
85
+ if (key.upArrow || input === 'k') {
86
+ setCursor((value) => Math.max(0, value - 1))
87
+ return
88
+ }
89
+
90
+ if (key.downArrow || input === 'j') {
91
+ setCursor((value) => Math.min(currentState.records.length - 1, value + 1))
92
+ return
93
+ }
94
+ }
95
+
96
+ if (!key.return || !activeRecord) {
97
+ return
98
+ }
99
+
100
+ if (currentState.step === 'record') {
101
+ setCurrentState((previousState) =>
102
+ advanceRestoreFlow(previousState, {
103
+ type: 'select-record',
104
+ timestamp: activeRecord.timestamp,
105
+ }),
106
+ )
107
+ return
108
+ }
109
+
110
+ if (currentState.step === 'target') {
111
+ if (activeRecord.action !== 'restore') {
112
+ return
113
+ }
114
+
115
+ setCurrentState((previousState) =>
116
+ advanceRestoreFlow(previousState, {
117
+ type: 'select-target',
118
+ targetType: activeRecord.targetType,
119
+ ...(activeRecord.targetType === 'preset'
120
+ ? { targetName: activeRecord.targetName }
121
+ : {}),
122
+ }),
123
+ )
124
+ return
125
+ }
126
+
127
+ if (currentState.step === 'confirm') {
128
+ onSubmit({
129
+ confirmed: true,
130
+ timestamp: activeRecord.timestamp,
131
+ ...(currentState.targetType ? { targetType: currentState.targetType } : {}),
132
+ ...(currentState.targetType === 'preset' && 'targetName' in currentState
133
+ ? { targetName: currentState.targetName }
134
+ : {}),
135
+ })
136
+ exit()
137
+ }
138
+ })
139
+
140
+ return (
141
+ <Box flexDirection="column">
142
+ <Text>Restore record</Text>
143
+ {currentState.step === 'record' ? (
144
+ <>
145
+ <Text dimColor>↑/k ↓/j navigate · enter confirm · q cancel</Text>
146
+ <Box marginTop={1}>
147
+ <Box flexDirection="column" width={28} marginRight={2}>
148
+ <Text bold color="cyan">History</Text>
149
+ <Box flexDirection="column" marginTop={1}>
150
+ {currentState.records.map((record, index) => (
151
+ <Text key={record.timestamp}>
152
+ {index === cursor ? '❯ ' : ' '}
153
+ {record.timestamp}
154
+ </Text>
155
+ ))}
156
+ </Box>
157
+ </Box>
158
+ <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="green" paddingX={1}>
159
+ <Text bold color="green">Preview</Text>
160
+ {activeRecord?.action === 'init' ? (
161
+ <Box flexDirection="column">
162
+ {fromFiles.length > 0 ? (
163
+ <Box flexDirection="column">
164
+ <Text dimColor>From:</Text>
165
+ {fromFiles.map((file) => (
166
+ <Text key={file} color="cyan"> {file}</Text>
167
+ ))}
168
+ </Box>
169
+ ) : null}
170
+ {toFiles.length > 0 ? (
171
+ <Box flexDirection="column">
172
+ <Text dimColor>To:</Text>
173
+ {toFiles.map((file) => (
174
+ <Text key={file} color="cyan"> {file}</Text>
175
+ ))}
176
+ </Box>
177
+ ) : null}
178
+ </Box>
179
+ ) : (
180
+ <Text dimColor>
181
+ Restore to {activeRecord?.targetType === 'preset' ? `preset ${activeRecord.targetName}` : activeRecord?.targetType ?? 'settings'}
182
+ </Text>
183
+ )}
184
+ <Box flexDirection="column" marginTop={1}>
185
+ <EnvEntries entries={restoreEntries} />
186
+ </Box>
187
+ </Box>
188
+ </Box>
189
+ </>
190
+ ) : null}
191
+ {currentState.step === 'target' ? (
192
+ <Text>
193
+ Select target for {selectedRecord?.timestamp ?? 'record'}: settings or preset
194
+ </Text>
195
+ ) : null}
196
+ {currentState.step === 'confirm' && selectedRecord?.action === 'init' ? (
197
+ <Box flexDirection="column" marginTop={1}>
198
+ <Text>
199
+ Confirm restore from <Text color="cyan">{selectedRecord.timestamp}</Text>
200
+ </Text>
201
+ <EnvSummary
202
+ title="Will restore"
203
+ entries={restoreEntries}
204
+ {...(fromFiles.length > 0 ? { fromFiles } : {})}
205
+ {...(toFiles.length > 0 ? { toFiles } : {})}
206
+ />
207
+ </Box>
208
+ ) : null}
209
+ {currentState.step === 'confirm' && selectedRecord?.action !== 'init' ? (
210
+ <Box flexDirection="column" marginTop={1}>
211
+ <Text>
212
+ Confirm restore from <Text color="cyan">{selectedRecord?.timestamp ?? 'record'}</Text> to{' '}
213
+ <Text color="green">
214
+ {currentState.targetType === 'preset'
215
+ ? `preset ${currentState.targetName}`
216
+ : currentState.targetType ?? 'settings'}
217
+ </Text>
218
+ </Text>
219
+ <EnvSummary title="Will restore" entries={restoreEntries} />
220
+ </Box>
221
+ ) : null}
222
+ {currentState.step === 'done' ? (
223
+ <Text color="green">{'\n'}Restore complete</Text>
224
+ ) : null}
225
+ {currentState.step !== 'done' ? (
226
+ <Text>Press Enter to confirm or q to cancel</Text>
227
+ ) : null}
228
+ </Box>
229
+ )
230
+ }
@@ -0,0 +1,83 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import { Box, Text, useApp, useInput } from 'ink'
3
+
4
+ import type { EnvMap } from '../core/schema.js'
5
+ import { EnvEntries } from './summary.js'
6
+
7
+ export type PresetSource = 'global' | 'project'
8
+
9
+ export type PresetSelectItem = {
10
+ name: string
11
+ env: EnvMap
12
+ source: PresetSource
13
+ }
14
+
15
+ type Props = {
16
+ presets: Array<PresetSelectItem>
17
+ defaultIndex?: number
18
+ onSubmit: (preset: PresetSelectItem) => void
19
+ }
20
+
21
+ export function RunPresetSelectApp({ presets, defaultIndex = 0, onSubmit }: Props) {
22
+ const { exit } = useApp()
23
+ const [cursor, setCursor] = useState(defaultIndex)
24
+ const active = presets[cursor]
25
+
26
+ const entries = useMemo(
27
+ () =>
28
+ active
29
+ ? (Object.entries(active.env).sort(([a], [b]) => a.localeCompare(b)) as [string, string][])
30
+ : [],
31
+ [active],
32
+ )
33
+
34
+ useInput((input, key) => {
35
+ if (key.escape || input.toLowerCase() === 'q') {
36
+ exit()
37
+ return
38
+ }
39
+
40
+ if (key.upArrow || input === 'k') {
41
+ setCursor((c) => Math.max(0, c - 1))
42
+ return
43
+ }
44
+
45
+ if (key.downArrow || input === 'j') {
46
+ setCursor((c) => Math.min(presets.length - 1, c + 1))
47
+ return
48
+ }
49
+
50
+ if (key.return && active) {
51
+ onSubmit(active)
52
+ exit()
53
+ }
54
+ })
55
+
56
+ return (
57
+ <Box flexDirection="column">
58
+ <Text bold>Select a preset</Text>
59
+ <Text dimColor>↑/k ↓/j navigate · enter select · q cancel</Text>
60
+ <Box marginTop={1}>
61
+ <Box flexDirection="column" width={28} marginRight={2}>
62
+ <Text bold color="cyan">Presets</Text>
63
+ <Box flexDirection="column" marginTop={1}>
64
+ {presets.map((p, i) => (
65
+ <Box key={`${p.source}:${p.name}`}>
66
+ <Text>{i === cursor ? '❯ ' : ' '}</Text>
67
+ <Text {...(p.source === 'project' ? { color: 'yellow' } : {})}>{p.name}</Text>
68
+ <Text dimColor> ({p.source})</Text>
69
+ </Box>
70
+ ))}
71
+ </Box>
72
+ </Box>
73
+ <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="green" paddingX={1}>
74
+ <Text bold color="green">{active?.name ?? 'Preview'}</Text>
75
+ <Text dimColor>{active?.source === 'project' ? 'Project preset' : 'Global preset'}</Text>
76
+ <Box flexDirection="column" marginTop={1}>
77
+ <EnvEntries entries={entries} />
78
+ </Box>
79
+ </Box>
80
+ </Box>
81
+ </Box>
82
+ )
83
+ }
@@ -0,0 +1,91 @@
1
+ import React from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { Box, Text, render } from 'ink'
4
+
5
+ const h = React.createElement
6
+
7
+ import { maskValue } from '../core/mask.js'
8
+ import type { EnvMap } from '../core/schema.js'
9
+
10
+ export function EnvEntries({ entries, mask }: { entries: [string, string][]; mask?: boolean }) {
11
+ if (entries.length === 0) {
12
+ return <Text dimColor>none</Text>
13
+ }
14
+ return <>{entries.map(([key, value]) => (
15
+ <Box key={key}>
16
+ <Text color="yellow">• </Text>
17
+ <Text color="magenta">{key}</Text>
18
+ <Text dimColor>=</Text>
19
+ <Text color="white">{mask ? maskValue(key, value) : value}</Text>
20
+ </Box>
21
+ ))}</>
22
+ }
23
+
24
+ export function EnvSummary({
25
+ title,
26
+ entries,
27
+ description,
28
+ fromFiles,
29
+ toFiles,
30
+ footer,
31
+ mask,
32
+ }: {
33
+ title: string
34
+ entries: [string, string][]
35
+ description?: string
36
+ fromFiles?: string[]
37
+ toFiles?: string[]
38
+ footer?: ReactNode
39
+ mask?: boolean
40
+ }) {
41
+ return (
42
+ <Box flexDirection="column">
43
+ {description ? <Text dimColor>{description}</Text> : null}
44
+ {fromFiles && fromFiles.length > 0 ? (
45
+ <Box flexDirection="column">
46
+ <Text dimColor>From:</Text>
47
+ {fromFiles.map((file) => (
48
+ <Text key={file} color="cyan"> {file}</Text>
49
+ ))}
50
+ </Box>
51
+ ) : null}
52
+ {toFiles && toFiles.length > 0 ? (
53
+ <Box flexDirection="column">
54
+ <Text dimColor>To:</Text>
55
+ {toFiles.map((file) => (
56
+ <Text key={file} color="cyan"> {file}</Text>
57
+ ))}
58
+ </Box>
59
+ ) : null}
60
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="green" paddingX={1}>
61
+ <Text bold color="green">{title}</Text>
62
+ <EnvEntries entries={entries} {...(mask ? { mask } : {})} />
63
+ </Box>
64
+ {footer ?? null}
65
+ </Box>
66
+ )
67
+ }
68
+
69
+ export async function renderEnvSummary(props: {
70
+ title: string
71
+ description?: string
72
+ env: EnvMap
73
+ fromFiles?: string[]
74
+ toFiles?: string[]
75
+ footer?: ReactNode
76
+ }): Promise<void> {
77
+ const entries: [string, string][] = Object.entries(props.env).sort(([a], [b]) => a.localeCompare(b)) as [string, string][]
78
+ const app = render(
79
+ h(EnvSummary, {
80
+ title: props.title,
81
+ entries,
82
+ mask: true,
83
+ ...(props.description ? { description: props.description } : {}),
84
+ ...(props.fromFiles ? { fromFiles: props.fromFiles } : {}),
85
+ ...(props.toFiles ? { toFiles: props.toFiles } : {}),
86
+ ...(props.footer ? { footer: props.footer } : {}),
87
+ }),
88
+ )
89
+ app.unmount()
90
+ await app.waitUntilExit()
91
+ }
@@ -0,0 +1,72 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import { atomicWriteFile } from '../core/fs.js'
4
+ import {
5
+ resolveClaudeSettingsLocalPath,
6
+ resolveClaudeSettingsPath,
7
+ resolveProjectSettingsLocalPath,
8
+ resolveProjectSettingsPath,
9
+ } from '../core/paths.js'
10
+ import { envMapSchema, type EnvMap } from '../core/schema.js'
11
+
12
+ export type ClaudeSettingsSource = {
13
+ path: string
14
+ exists: boolean
15
+ env: EnvMap
16
+ }
17
+
18
+ export function createClaudeSettingsEnvService({ homeDir, cwd }: { homeDir?: string; cwd?: string } = {}) {
19
+ const paths = [
20
+ resolveClaudeSettingsPath(homeDir),
21
+ resolveClaudeSettingsLocalPath(homeDir),
22
+ resolveProjectSettingsPath(cwd),
23
+ resolveProjectSettingsLocalPath(cwd),
24
+ ]
25
+
26
+ async function readOne(path: string): Promise<ClaudeSettingsSource> {
27
+ try {
28
+ const content = await readFile(path, 'utf8')
29
+ const json = JSON.parse(content) as { env?: unknown }
30
+ return {
31
+ path,
32
+ exists: true,
33
+ env: envMapSchema.parse(json.env ?? {}),
34
+ }
35
+ } catch (error) {
36
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
37
+ return {
38
+ path,
39
+ exists: false,
40
+ env: envMapSchema.parse({}),
41
+ }
42
+ }
43
+
44
+ throw error
45
+ }
46
+ }
47
+
48
+ async function writeOne(path: string, env: EnvMap): Promise<void> {
49
+ let json: Record<string, unknown> = {}
50
+
51
+ try {
52
+ const content = await readFile(path, 'utf8')
53
+ json = JSON.parse(content) as Record<string, unknown>
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
56
+ throw error
57
+ }
58
+ }
59
+
60
+ json.env = envMapSchema.parse(env)
61
+ await atomicWriteFile(path, `${JSON.stringify(json, null, 2)}\n`)
62
+ }
63
+
64
+ return {
65
+ read: () => Promise.all(paths.map(readOne)),
66
+ write: async (sources: Array<{ path: string; env: EnvMap }>) => {
67
+ for (const { path, env } of sources) {
68
+ await writeOne(path, env)
69
+ }
70
+ },
71
+ }
72
+ }
@@ -0,0 +1,48 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { atomicWriteFile, ensureParentDir } from '../core/fs.js'
5
+ import { resolveHistoryPath } from '../core/paths.js'
6
+ import { historySchema } from '../core/schema.js'
7
+ import type { HistoryRecord } from '../core/schema.js'
8
+
9
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
10
+ return error instanceof Error && 'code' in error
11
+ }
12
+
13
+ export function createHistoryService(globalRoot: string) {
14
+ return {
15
+ async write(record: HistoryRecord): Promise<HistoryRecord> {
16
+ const stored = historySchema.parse(record)
17
+ const filePath = resolveHistoryPath(globalRoot, stored.timestamp)
18
+ await ensureParentDir(filePath)
19
+ await atomicWriteFile(filePath, `${JSON.stringify(stored, null, 2)}\n`)
20
+ return stored
21
+ },
22
+
23
+ async list(): Promise<HistoryRecord[]> {
24
+ const dirPath = dirname(resolveHistoryPath(globalRoot, 'placeholder'))
25
+
26
+ try {
27
+ const entries = await readdir(dirPath, { withFileTypes: true })
28
+ const fileNames = entries
29
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
30
+ .map((entry) => entry.name)
31
+ .sort((a, b) => b.localeCompare(a))
32
+
33
+ return Promise.all(
34
+ fileNames.map(async (fileName) => {
35
+ const content = await readFile(join(dirPath, fileName), 'utf8')
36
+ return historySchema.parse(JSON.parse(content))
37
+ }),
38
+ )
39
+ } catch (error) {
40
+ if (isErrnoException(error) && error.code === 'ENOENT') {
41
+ return []
42
+ }
43
+
44
+ throw error
45
+ }
46
+ },
47
+ }
48
+ }
@@ -0,0 +1,72 @@
1
+ import { readdir, readFile, rm } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { CliError } from '../core/errors.js'
5
+ import { atomicWriteFile } from '../core/fs.js'
6
+ import { resolvePresetPath } from '../core/paths.js'
7
+ import { presetSchema, type Preset } from '../core/schema.js'
8
+
9
+ type StoredPreset = Preset & { filePath: string }
10
+
11
+ export function createPresetService(globalRoot: string) {
12
+ function getPath(name: string): string {
13
+ return resolvePresetPath(globalRoot, name)
14
+ }
15
+
16
+ return {
17
+ getPath,
18
+
19
+ async write(preset: Preset): Promise<StoredPreset> {
20
+ const parsed = presetSchema.parse(preset)
21
+ const filePath = getPath(parsed.name)
22
+ await atomicWriteFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`)
23
+ return { ...parsed, filePath }
24
+ },
25
+
26
+ async read(name: string): Promise<StoredPreset> {
27
+ const filePath = getPath(name)
28
+
29
+ try {
30
+ const content = await readFile(filePath, 'utf8')
31
+ const preset = presetSchema.parse(JSON.parse(content))
32
+ return { ...preset, filePath }
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
35
+ throw new CliError(`Preset not found: ${name}`)
36
+ }
37
+
38
+ throw error
39
+ }
40
+ },
41
+
42
+ async listNames(): Promise<string[]> {
43
+ const dirPath = dirname(getPath('placeholder'))
44
+
45
+ try {
46
+ const entries = await readdir(dirPath, { withFileTypes: true })
47
+ return entries
48
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
49
+ .map((entry) => entry.name.slice(0, -'.json'.length))
50
+ .sort()
51
+ } catch (error) {
52
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
53
+ return []
54
+ }
55
+
56
+ throw error
57
+ }
58
+ },
59
+
60
+ async remove(name: string): Promise<void> {
61
+ const filePath = getPath(name)
62
+
63
+ try {
64
+ await rm(filePath)
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
67
+ throw error
68
+ }
69
+ }
70
+ },
71
+ }
72
+ }