@demirarch/recode 0.1.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.
@@ -0,0 +1,231 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput, useApp } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import Spinner from 'ink-spinner';
5
+ import { useAgent, type DisplayMessage } from '../hooks/useAgent.js';
6
+ import { TopBar } from './TopBar.js';
7
+ import { StatusBar } from './StatusBar.js';
8
+ import { parseCommand } from '../lib/commands.js';
9
+ import { saveConfig } from '../lib/config.js';
10
+ import path from 'path';
11
+
12
+ const MODEL_EXAMPLES = [
13
+ 'anthropic/claude-sonnet-4-5',
14
+ 'openai/gpt-4o',
15
+ 'google/gemini-2.5-pro',
16
+ 'deepseek/deepseek-r1',
17
+ 'meta-llama/llama-4-maverick',
18
+ 'mistralai/codestral-latest',
19
+ 'qwen/qwen3-235b-a22b',
20
+ 'x-ai/grok-3',
21
+ ];
22
+
23
+ interface ChatScreenProps {
24
+ model: string;
25
+ apiKey: string;
26
+ initialCwd: string;
27
+ onModelChange: (model: string) => void;
28
+ }
29
+
30
+ // ── Message Renderer ─────────────────────────────────────────
31
+
32
+ function RenderMessage({ msg }: { msg: DisplayMessage }) {
33
+ switch (msg.type) {
34
+ case 'user':
35
+ return (
36
+ <Box flexDirection="column" marginBottom={1}>
37
+ <Text bold color="#F26207">You</Text>
38
+ <Text>{msg.content}</Text>
39
+ </Box>
40
+ );
41
+
42
+ case 'assistant':
43
+ return (
44
+ <Box flexDirection="column" marginBottom={1}>
45
+ <Text bold color="cyan">Recode</Text>
46
+ <Text wrap="wrap">{msg.content}</Text>
47
+ </Box>
48
+ );
49
+
50
+ case 'tool_call': {
51
+ let preview = '';
52
+ try {
53
+ const args = JSON.parse(msg.args) as Record<string, unknown>;
54
+ const firstVal = Object.values(args)[0];
55
+ preview = typeof firstVal === 'string' ? firstVal.slice(0, 50) : '';
56
+ } catch { preview = ''; }
57
+
58
+ const icon = msg.status === 'running' ? '⟳' : msg.status === 'done' ? '✓' : '✗';
59
+ const color = msg.status === 'running' ? 'yellow' : msg.status === 'done' ? 'green' : 'red';
60
+
61
+ return (
62
+ <Box marginBottom={0}>
63
+ <Text color={color}>{icon} </Text>
64
+ <Text color="gray">{msg.name}</Text>
65
+ {preview ? <Text color="gray"> {preview}</Text> : null}
66
+ </Box>
67
+ );
68
+ }
69
+
70
+ case 'system':
71
+ return (
72
+ <Box flexDirection="column" marginBottom={1} borderStyle="single" borderColor="gray" paddingX={1}>
73
+ <Text color="gray">{msg.content}</Text>
74
+ </Box>
75
+ );
76
+
77
+ case 'error':
78
+ return (
79
+ <Box marginBottom={1}>
80
+ <Text color="red">Error: {msg.content}</Text>
81
+ </Box>
82
+ );
83
+
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+
89
+ // ── Model Input Overlay ───────────────────────────────────────
90
+
91
+ function ModelOverlay({
92
+ currentModel,
93
+ onConfirm,
94
+ onCancel,
95
+ }: {
96
+ currentModel: string;
97
+ onConfirm: (model: string) => void;
98
+ onCancel: () => void;
99
+ }) {
100
+ const [value, setValue] = useState(currentModel);
101
+ const [error, setError] = useState<string | null>(null);
102
+
103
+ useInput((_, key) => {
104
+ if (key.escape) { onCancel(); return; }
105
+ if (key.return) {
106
+ const m = value.trim();
107
+ if (!m.includes('/')) {
108
+ setError('Must include provider, e.g. anthropic/claude-sonnet-4-5');
109
+ return;
110
+ }
111
+ onConfirm(m);
112
+ }
113
+ });
114
+
115
+ return (
116
+ <Box flexDirection="column" borderStyle="double" borderColor="#F26207" padding={1} marginX={2}>
117
+ <Text bold color="#F26207">Change Model <Text color="gray">(Esc to cancel)</Text></Text>
118
+ <Box marginTop={1}>
119
+ <Text color="#F26207">{'> '}</Text>
120
+ <TextInput value={value} onChange={setValue} placeholder="provider/model-name" />
121
+ </Box>
122
+ <Text color="gray" dimColor>Press Enter to confirm</Text>
123
+
124
+ <Box flexDirection="column" marginTop={1}>
125
+ <Text color="gray" dimColor>Examples:</Text>
126
+ {MODEL_EXAMPLES.map((e) => (
127
+ <Text key={e} color="gray" dimColor> {e}</Text>
128
+ ))}
129
+ </Box>
130
+
131
+ {error && <Text color="red">{error}</Text>}
132
+ </Box>
133
+ );
134
+ }
135
+
136
+ // ── Main Chat Screen ─────────────────────────────────────────
137
+
138
+ export function ChatScreen({ model, apiKey, initialCwd, onModelChange }: ChatScreenProps) {
139
+ const { exit } = useApp();
140
+ const [cwd, setCwd] = useState(initialCwd);
141
+ const [input, setInput] = useState('');
142
+ const [showModelInput, setShowModelInput] = useState(false);
143
+
144
+ const { display, thinking, sendMessage, clearHistory, addSystemMessage } =
145
+ useAgent(model, apiKey, cwd);
146
+
147
+ const handleSubmit = async (value: string) => {
148
+ const trimmed = value.trim();
149
+ if (!trimmed || thinking) return;
150
+ setInput('');
151
+
152
+ const result = parseCommand(trimmed, cwd);
153
+
154
+ switch (result.type) {
155
+ case 'system': addSystemMessage(result.content); return;
156
+ case 'open_model_select': setShowModelInput(true); return;
157
+ case 'clear': clearHistory(); return;
158
+ case 'exit': exit(); return;
159
+ case 'cd': {
160
+ const newCwd = path.isAbsolute(result.path)
161
+ ? result.path
162
+ : path.resolve(cwd, result.path);
163
+ setCwd(newCwd);
164
+ addSystemMessage(`cwd: ${newCwd}`);
165
+ return;
166
+ }
167
+ case 'send_to_ai': await sendMessage(result.content); return;
168
+ case 'none': await sendMessage(trimmed); return;
169
+ }
170
+ };
171
+
172
+ const handleModelConfirm = async (newModel: string) => {
173
+ setShowModelInput(false);
174
+ onModelChange(newModel);
175
+ try { await saveConfig({ apiKey, model: newModel }); } catch { /* ignore */ }
176
+ addSystemMessage(`Model: ${newModel}`);
177
+ };
178
+
179
+ useInput((inp, key) => {
180
+ if (key.ctrl && inp === 'l') clearHistory();
181
+ if (key.ctrl && inp === 'c') exit();
182
+ });
183
+
184
+ const visibleMessages = display.slice(-40);
185
+
186
+ return (
187
+ <Box flexDirection="column" height="100%">
188
+ <TopBar model={model} cwd={cwd} />
189
+
190
+ {showModelInput ? (
191
+ <ModelOverlay
192
+ currentModel={model}
193
+ onConfirm={(m) => { void handleModelConfirm(m); }}
194
+ onCancel={() => setShowModelInput(false)}
195
+ />
196
+ ) : (
197
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
198
+ {visibleMessages.length === 0 && (
199
+ <Box marginTop={1}>
200
+ <Text color="gray">
201
+ Type a message or <Text color="#F26207">/help</Text> for commands.
202
+ </Text>
203
+ </Box>
204
+ )}
205
+ {visibleMessages.map((msg, i) => (
206
+ <RenderMessage key={i} msg={msg} />
207
+ ))}
208
+ {thinking && (
209
+ <Box>
210
+ <Spinner type="dots" />
211
+ <Text color="#F26207"> Thinking...</Text>
212
+ </Box>
213
+ )}
214
+ </Box>
215
+ )}
216
+
217
+ <Box borderStyle="single" borderColor="#F26207" paddingX={1}>
218
+ <Text bold color="#F26207">{'> '}</Text>
219
+ <TextInput
220
+ value={input}
221
+ onChange={setInput}
222
+ onSubmit={(v) => { void handleSubmit(v); }}
223
+ placeholder={thinking ? 'Waiting...' : 'Message Recode...'}
224
+ focus={!showModelInput}
225
+ />
226
+ </Box>
227
+
228
+ <StatusBar />
229
+ </Box>
230
+ );
231
+ }
@@ -0,0 +1,113 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { saveConfig, type RecodeConfig } from '../lib/config.js';
5
+
6
+ const EXAMPLES = [
7
+ 'anthropic/claude-sonnet-4-5',
8
+ 'openai/gpt-4o',
9
+ 'google/gemini-2.5-pro',
10
+ 'deepseek/deepseek-r1',
11
+ 'meta-llama/llama-4-maverick',
12
+ 'mistralai/codestral-latest',
13
+ 'qwen/qwen3-235b-a22b',
14
+ 'x-ai/grok-3',
15
+ ];
16
+
17
+ type Step = 'apiKey' | 'model' | 'saving';
18
+
19
+ interface SetupScreenProps {
20
+ onComplete: (config: RecodeConfig) => void;
21
+ }
22
+
23
+ export function SetupScreen({ onComplete }: SetupScreenProps) {
24
+ const [step, setStep] = useState<Step>('apiKey');
25
+ const [apiKey, setApiKey] = useState('');
26
+ const [model, setModel] = useState('');
27
+ const [error, setError] = useState<string | null>(null);
28
+
29
+ useInput((_, key) => {
30
+ if (!key.return) return;
31
+
32
+ if (step === 'apiKey') {
33
+ if (apiKey.trim().length < 10) {
34
+ setError('API key too short — paste your OpenRouter key');
35
+ return;
36
+ }
37
+ setError(null);
38
+ setStep('model');
39
+ return;
40
+ }
41
+
42
+ if (step === 'model') {
43
+ const m = model.trim();
44
+ if (!m.includes('/')) {
45
+ setError('Model must include provider, e.g. anthropic/claude-sonnet-4-5');
46
+ return;
47
+ }
48
+ setError(null);
49
+ void save(m);
50
+ }
51
+ });
52
+
53
+ const save = async (m: string) => {
54
+ setStep('saving');
55
+ const config: RecodeConfig = { apiKey: apiKey.trim(), model: m };
56
+ try {
57
+ await saveConfig(config);
58
+ onComplete(config);
59
+ } catch (err) {
60
+ setError(err instanceof Error ? err.message : String(err));
61
+ setStep('model');
62
+ }
63
+ };
64
+
65
+ return (
66
+ <Box flexDirection="column" padding={2} gap={1}>
67
+ <Box flexDirection="column">
68
+ <Text bold color="#F26207">Recode — First Launch Setup</Text>
69
+ <Text color="gray">Autonomous AI coding agent by DemirArch</Text>
70
+ <Text color="gray">Config: ~/recode-data/config.json</Text>
71
+ </Box>
72
+
73
+ {step === 'apiKey' && (
74
+ <Box flexDirection="column" gap={1} marginTop={1}>
75
+ <Text>OpenRouter API key:</Text>
76
+ <Text color="gray" dimColor>Get one at openrouter.ai/keys</Text>
77
+ <Box>
78
+ <Text color="#F26207">{'> '}</Text>
79
+ <TextInput value={apiKey} onChange={setApiKey} mask="*" placeholder="sk-or-..." />
80
+ </Box>
81
+ <Text color="gray" dimColor>Press Enter to continue</Text>
82
+ {error && <Text color="red">{error}</Text>}
83
+ </Box>
84
+ )}
85
+
86
+ {step === 'model' && (
87
+ <Box flexDirection="column" gap={1} marginTop={1}>
88
+ <Text>Model ID <Text color="gray">(provider/model-name)</Text></Text>
89
+ <Box>
90
+ <Text color="#F26207">{'> '}</Text>
91
+ <TextInput
92
+ value={model}
93
+ onChange={setModel}
94
+ placeholder="anthropic/claude-sonnet-4-5"
95
+ />
96
+ </Box>
97
+ <Text color="gray" dimColor>Press Enter to confirm • /model to change later</Text>
98
+
99
+ <Box flexDirection="column" marginTop={1}>
100
+ <Text color="gray" dimColor>Examples:</Text>
101
+ {EXAMPLES.map((e) => (
102
+ <Text key={e} color="gray" dimColor> {e}</Text>
103
+ ))}
104
+ </Box>
105
+
106
+ {error && <Text color="red">{error}</Text>}
107
+ </Box>
108
+ )}
109
+
110
+ {step === 'saving' && <Text color="#F26207">Saving config...</Text>}
111
+ </Box>
112
+ );
113
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ const CMD = ({ c, label }: { c: string; label: string }) => (
5
+ <Box gap={0} marginRight={2}>
6
+ <Text bold color="#F26207">{c}</Text>
7
+ <Text color="gray"> {label}</Text>
8
+ </Box>
9
+ );
10
+
11
+ export function StatusBar() {
12
+ return (
13
+ <Box borderStyle="single" borderColor="gray" paddingX={1}>
14
+ <CMD c="/help" label="help" />
15
+ <CMD c="/model" label="model" />
16
+ <CMD c="/clear" label="clear" />
17
+ <CMD c="/cd" label="cd" />
18
+ <CMD c="/ls" label="ls" />
19
+ <CMD c="/read" label="read" />
20
+ <CMD c="/exit" label="exit" />
21
+ </Box>
22
+ );
23
+ }
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { getModelLabel } from '../lib/models.js';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ interface TopBarProps {
8
+ model: string;
9
+ cwd: string;
10
+ }
11
+
12
+ function shortenPath(p: string): string {
13
+ const home = os.homedir();
14
+ if (p.startsWith(home)) return '~' + p.slice(home.length);
15
+ return p;
16
+ }
17
+
18
+ export function TopBar({ model, cwd }: TopBarProps) {
19
+ const shortModel = getModelLabel(model);
20
+ const shortCwd = shortenPath(cwd);
21
+
22
+ return (
23
+ <Box borderStyle="single" borderColor="#F26207" paddingX={1} justifyContent="space-between">
24
+ <Box gap={2}>
25
+ <Text bold color="#F26207">Recode</Text>
26
+ <Text color="gray">{shortCwd}</Text>
27
+ </Box>
28
+ <Text color="gray">
29
+ {'model: '}<Text color="#F26207">{shortModel}</Text>
30
+ </Text>
31
+ </Box>
32
+ );
33
+ }
@@ -0,0 +1,132 @@
1
+ import { useState, useRef, useCallback } from 'react';
2
+ import { callOpenRouter, type Message, type ToolCall } from '../lib/openrouter.js';
3
+ import { executeTool } from '../lib/tools.js';
4
+
5
+ export type DisplayMessage =
6
+ | { type: 'user'; content: string }
7
+ | { type: 'assistant'; content: string }
8
+ | { type: 'tool_call'; name: string; args: string; status: 'running' | 'done' | 'error'; result?: string }
9
+ | { type: 'system'; content: string }
10
+ | { type: 'error'; content: string };
11
+
12
+ export function useAgent(model: string, apiKey: string, cwd: string) {
13
+ const displayRef = useRef<DisplayMessage[]>([]);
14
+ const [display, setDisplay] = useState<DisplayMessage[]>([]);
15
+ const [apiMessages, setApiMessages] = useState<Message[]>([]);
16
+ const [thinking, setThinking] = useState(false);
17
+
18
+ // Sync ref → state
19
+ const flush = () => setDisplay([...displayRef.current]);
20
+
21
+ const addDisplay = (msg: DisplayMessage): number => {
22
+ const idx = displayRef.current.length;
23
+ displayRef.current = [...displayRef.current, msg];
24
+ flush();
25
+ return idx;
26
+ };
27
+
28
+ const updateDisplay = (idx: number, updates: Partial<DisplayMessage>) => {
29
+ displayRef.current = displayRef.current.map((m, i) =>
30
+ i === idx ? { ...m, ...updates } as DisplayMessage : m
31
+ );
32
+ flush();
33
+ };
34
+
35
+ const addSystemMessage = useCallback((content: string) => {
36
+ addDisplay({ type: 'system', content });
37
+ }, []);
38
+
39
+ const clearHistory = useCallback(() => {
40
+ displayRef.current = [];
41
+ setDisplay([]);
42
+ setApiMessages([]);
43
+ }, []);
44
+
45
+ const sendMessage = useCallback(
46
+ async (userInput: string) => {
47
+ if (thinking) return;
48
+
49
+ // Add user message to display
50
+ addDisplay({ type: 'user', content: userInput });
51
+ setThinking(true);
52
+
53
+ // Build current API messages
54
+ const newUserMsg: Message = { role: 'user', content: userInput };
55
+ let currentMessages: Message[] = [...apiMessages, newUserMsg];
56
+ setApiMessages(currentMessages);
57
+
58
+ try {
59
+ // Agentic loop — run until no more tool calls
60
+ while (true) {
61
+ const response = await callOpenRouter(currentMessages, model, apiKey);
62
+
63
+ if (response.tool_calls && response.tool_calls.length > 0) {
64
+ // Record assistant message with tool calls
65
+ currentMessages = [
66
+ ...currentMessages,
67
+ {
68
+ role: 'assistant',
69
+ content: response.content,
70
+ tool_calls: response.tool_calls,
71
+ },
72
+ ];
73
+
74
+ // Execute each tool
75
+ const toolResultMessages: Message[] = [];
76
+
77
+ for (const tc of response.tool_calls) {
78
+ const displayIdx = addDisplay({
79
+ type: 'tool_call',
80
+ name: tc.function.name,
81
+ args: tc.function.arguments,
82
+ status: 'running',
83
+ });
84
+
85
+ let result: string;
86
+ let status: 'done' | 'error' = 'done';
87
+
88
+ try {
89
+ const parsedArgs = JSON.parse(tc.function.arguments) as Record<string, unknown>;
90
+ result = await executeTool(tc.function.name, parsedArgs, cwd);
91
+ } catch (err) {
92
+ result = `Error: ${err instanceof Error ? err.message : String(err)}`;
93
+ status = 'error';
94
+ }
95
+
96
+ updateDisplay(displayIdx, { status, result });
97
+
98
+ toolResultMessages.push({
99
+ role: 'tool',
100
+ content: result,
101
+ tool_call_id: tc.id,
102
+ });
103
+ }
104
+
105
+ currentMessages = [...currentMessages, ...toolResultMessages];
106
+ } else {
107
+ // Final text response
108
+ const content = response.content ?? '(no response)';
109
+ addDisplay({ type: 'assistant', content });
110
+ setApiMessages(currentMessages);
111
+ break;
112
+ }
113
+ }
114
+ } catch (err) {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ addDisplay({ type: 'error', content: msg });
117
+ } finally {
118
+ setThinking(false);
119
+ }
120
+ },
121
+ [apiMessages, model, apiKey, cwd, thinking]
122
+ );
123
+
124
+ return {
125
+ display,
126
+ thinking,
127
+ sendMessage,
128
+ clearHistory,
129
+ addSystemMessage,
130
+ apiMessages,
131
+ };
132
+ }
@@ -0,0 +1,101 @@
1
+ import { MODELS } from './models.js';
2
+
3
+ export type CommandResult =
4
+ | { type: 'system'; content: string }
5
+ | { type: 'open_model_select' }
6
+ | { type: 'clear' }
7
+ | { type: 'exit' }
8
+ | { type: 'cd'; path: string }
9
+ | { type: 'none' }
10
+ | { type: 'send_to_ai'; content: string };
11
+
12
+ export const HELP_TEXT = `
13
+ Recode — AI Coding Agent (github.com/demirgitbuh/recode)
14
+
15
+ SLASH COMMANDS
16
+ /help Show this help
17
+ /model Change AI model
18
+ /models List all available models
19
+ /clear Clear conversation history
20
+ /exit /quit Exit Recode
21
+ /cd <path> Change working directory
22
+ /ls [path] List directory contents
23
+ /read <path> Read file and show contents
24
+ /tools List available AI tools
25
+ /cwd Show current working directory
26
+ /version Show Recode version
27
+
28
+ KEYBINDINGS
29
+ Enter Send message
30
+ Ctrl+C Exit
31
+ Ctrl+L Clear screen
32
+ `.trim();
33
+
34
+ const TOOLS_TEXT = `
35
+ AI TOOLS (called automatically by the AI)
36
+ read_file <path> Read file contents
37
+ write_file <path> <content> Create or overwrite a file
38
+ list_directory [path] List directory entries
39
+ create_directory <path> Create directory (recursive)
40
+ delete_file <path> Delete a file
41
+ execute_command <cmd> Run shell command in cwd
42
+ search_files <pattern> Search files for pattern
43
+ `.trim();
44
+
45
+ export function parseCommand(input: string, cwd: string): CommandResult {
46
+ const trimmed = input.trim();
47
+ if (!trimmed.startsWith('/')) return { type: 'none' };
48
+
49
+ const [cmd, ...rest] = trimmed.slice(1).split(' ');
50
+ const arg = rest.join(' ').trim();
51
+
52
+ switch (cmd.toLowerCase()) {
53
+ case 'help':
54
+ return { type: 'system', content: HELP_TEXT };
55
+
56
+ case 'model':
57
+ return { type: 'open_model_select' };
58
+
59
+ case 'models': {
60
+ const list = MODELS.map((m) => ` ${m.provider.padEnd(11)} ${m.label.padEnd(22)} ${m.id}`).join('\n');
61
+ return { type: 'system', content: `Available models:\n\n${list}` };
62
+ }
63
+
64
+ case 'clear':
65
+ return { type: 'clear' };
66
+
67
+ case 'exit':
68
+ case 'quit':
69
+ case 'q':
70
+ return { type: 'exit' };
71
+
72
+ case 'cd':
73
+ if (!arg) return { type: 'system', content: `Usage: /cd <path>` };
74
+ return { type: 'cd', path: arg };
75
+
76
+ case 'ls': {
77
+ const p = arg || cwd;
78
+ return { type: 'send_to_ai', content: `List the directory: ${p}` };
79
+ }
80
+
81
+ case 'read': {
82
+ if (!arg) return { type: 'system', content: `Usage: /read <path>` };
83
+ return { type: 'send_to_ai', content: `Read and show the file: ${arg}` };
84
+ }
85
+
86
+ case 'tools':
87
+ return { type: 'system', content: TOOLS_TEXT };
88
+
89
+ case 'cwd':
90
+ return { type: 'system', content: `Working directory: ${cwd}` };
91
+
92
+ case 'version':
93
+ return { type: 'system', content: 'Recode v0.1.0 — by DemirArch' };
94
+
95
+ default:
96
+ return {
97
+ type: 'system',
98
+ content: `Unknown command: /${cmd}\nType /help for available commands.`,
99
+ };
100
+ }
101
+ }
@@ -0,0 +1,29 @@
1
+ import { promises as fs } from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ export interface RecodeConfig {
6
+ apiKey: string;
7
+ model: string;
8
+ }
9
+
10
+ const CONFIG_DIR = path.join(os.homedir(), 'recode-data');
11
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
12
+
13
+ export async function loadConfig(): Promise<RecodeConfig | null> {
14
+ try {
15
+ const raw = await fs.readFile(CONFIG_PATH, 'utf-8');
16
+ const parsed = JSON.parse(raw) as Partial<RecodeConfig>;
17
+ if (parsed.apiKey && parsed.model) {
18
+ return { apiKey: parsed.apiKey, model: parsed.model };
19
+ }
20
+ return null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export async function saveConfig(config: RecodeConfig): Promise<void> {
27
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
28
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
29
+ }