@sean.holung/minicode 0.3.5 → 0.3.6

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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # minicode
2
2
 
3
+ > Now supports connecting to [OpenRouter](https://openrouter.ai/) account via minicode UI. Sign in with OpenRouter account. Use for free with compatible free tier OpenRouter hosted models from MiniMax, Nvidia, Qwen, Google, etc.
4
+
3
5
  A graph-native coding agent and code exploration environment built around structural context optimization that leverages symbol-aware retrieval, dependency graphs, and targeted context. It started as a way to make local models viable under tighter context budgets, and it now also works well with hosted frontier models through the same runtime, web UI, and OpenAI-compatible serve mode.
4
6
 
5
7
  minicode is built on a simple bet: models perform better when you give them less, but better context. Bloated context directly degrades output quality: attention dilutes, positional biases cause mid-context information loss, and inference latency grows as token count increases.
@@ -10,6 +12,16 @@ _Run `minicode serve` to get the web UI on localhost: chat, tool activity, sessi
10
12
 
11
13
  <img width="1723" height="920" alt="Screenshot 2026-03-26 at 6 30 23 PM" src="https://github.com/user-attachments/assets/499c8dc7-cc2b-4125-abd5-32b2fc9795ea" />
12
14
 
15
+ ## Quick Start (OpenRouter)
16
+ ```bash
17
+ npm install -g @sean.holung/minicode
18
+ minicode serve
19
+ ```
20
+
21
+ 1. Navigate to [localhost:4567](http://localhost:4567)
22
+ 2. Click Connect OpenRouter to sign in to [OpenRouter](https://openrouter.ai/) and connect account.
23
+ 3. Choose a model. Choose a (free) model if on free tier. Model must support tool use.
24
+
13
25
 
14
26
  ## Quick Start (LM Studio)
15
27
 
@@ -19,8 +31,8 @@ _Run `minicode serve` to get the web UI on localhost: chat, tool activity, sessi
19
31
  # 2. Install
20
32
  npm install -g @sean.holung/minicode
21
33
 
22
- # 3. Configure (~/.minicode/agent.config.json is auto-created on first run)
23
- # Set your model name — minicode will prompt you if this is missing.
34
+ # 3. Configure
35
+ # Set your model name in ~/.minicode/.env — minicode will prompt you if this is missing.
24
36
  cat > ~/.minicode/.env << 'EOF'
25
37
  MODEL_PROVIDER=openai-compatible
26
38
  MODEL=your-model-name
@@ -164,12 +176,12 @@ See [docs/PLUGIN_SPEC.md](docs/PLUGIN_SPEC.md) for the full specification. Quick
164
176
 
165
177
  ## Configuration
166
178
 
167
- Configuration can come from (later sources override earlier):
179
+ Configuration can come from:
168
180
 
169
- 1. `~/.minicode/.env` — User-level defaults (API keys, model, etc.)
170
- 2. `~/.minicode/agent.config.json`User-level JSON config
171
- 3. Project `.env` and `agent.config.json` in workspace root
172
- 4. Environment variables (highest precedence)
181
+ 1. `~/.minicode/.env` — User-level defaults (API keys, model, runtime settings)
182
+ 2. Environment variables Shell overrides for the current process
183
+
184
+ When the same key is set in both places, the exported environment variable wins.
173
185
 
174
186
  Nothing is written inside your workspace; config and cache live under `~/.minicode/`.
175
187
 
@@ -184,6 +196,7 @@ Nothing is written inside your workspace; config and cache live under `~/.minico
184
196
  | `OPENAI_BASE_URL` | No | `http://localhost:1234/v1` | Base URL for OpenAI-compatible API (LM Studio, etc.) |
185
197
  | `OPENAI_API_KEY` | No | none | Optional for local servers; required if your endpoint enforces auth |
186
198
  | `OPENROUTER_API_KEY` | No | none | Preferred key when `OPENAI_BASE_URL` points at OpenRouter; falls back to `OPENAI_API_KEY` if unset |
199
+ | `COMMAND_DENYLIST` | No | none | Optional JSON array or comma-separated regex patterns appended to the built-in destructive-command denylist |
187
200
  | `MAX_STEPS` | No | `50` | Max agent loop iterations per user turn |
188
201
  | `MAX_TOKENS` | No | `4096` | Max model output tokens per model call |
189
202
  | `MAX_CONTEXT_TOKENS` | No | `32000` | Approximate session history trimming target. For small models (e.g. 8k context), set lower (e.g. `6000`) to leave room for responses. |
@@ -202,44 +215,7 @@ Nothing is written inside your workspace; config and cache live under `~/.minico
202
215
  | `REASONING_EFFORT` | No | unset | Reasoning level for providers that support it. Valid values: `xhigh`, `high`, `medium`, `low`, `minimal`, `none` |
203
216
 
204
217
 
205
- ### `agent.config.json`
206
-
207
- A global `~/.minicode/agent.config.json` is auto-created on first run. Only set what you need — everything has sensible defaults:
208
-
209
- ```json
210
- {
211
- "modelProvider": "openai-compatible",
212
- "model": "your-model-name",
213
- "openAiBaseUrl": "http://localhost:1234/v1",
214
- "maxSteps": 50,
215
- "maxTokens": 4096,
216
- "maxContextTokens": 32000
217
- }
218
- ```
219
-
220
- Field mapping:
221
-
222
- - `modelProvider` ↔ `MODEL_PROVIDER`
223
- - `model` ↔ `MODEL`
224
- - `maxSteps` ↔ `MAX_STEPS`
225
- - `workspaceRoot` ↔ `WORKSPACE_ROOT`
226
- - `maxTokens` ↔ `MAX_TOKENS`
227
- - `maxContextTokens` ↔ `MAX_CONTEXT_TOKENS`
228
- - `commandTimeout` ↔ `COMMAND_TIMEOUT_MS`
229
- - `commandDenylist` ↔ no env equivalent (config-only)
230
- - `confirmDestructive` ↔ `CONFIRM_DESTRUCTIVE`
231
- - `maxFileSizeBytes` ↔ `MAX_FILE_SIZE_BYTES`
232
- - `keepRecentMessages` ↔ `KEEP_RECENT_MESSAGES`
233
- - `loopDetectionWindow` ↔ `LOOP_DETECTION_WINDOW`
234
- - `openAiBaseUrl` ↔ `OPENAI_BASE_URL`
235
- - `openAiApiKey` ↔ `OPENAI_API_KEY` / `OPENROUTER_API_KEY` (when using OpenRouter)
236
- - `maxToolOutputChars` ↔ `MAX_TOOL_OUTPUT_CHARS`
237
- - `enableFileReadDedup` ↔ `ENABLE_FILE_READ_DEDUP`
238
- - `enableAdaptiveKeepRecent` ↔ `ENABLE_ADAPTIVE_KEEP_RECENT`
239
- - `enableToolOutputTruncation` ↔ `ENABLE_TOOL_OUTPUT_TRUNCATION`
240
- - `compactionThreshold` ↔ `COMPACTION_THRESHOLD`
241
- - `compactionModel` ↔ `COMPACTION_MODEL`
242
- - `reasoningEffort` ↔ `REASONING_EFFORT`
218
+ All persisted user-level settings now live in `~/.minicode/.env`. The web UI settings dialog and `/config set` both update that file directly for non-secret runtime defaults.
243
219
 
244
220
  ## Usage
245
221
 
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
@@ -10,7 +10,7 @@ export const MINICODE_HOME = path.join(os.homedir(), ".minicode");
10
10
  */
11
11
  export function formatConfigForDisplay(config) {
12
12
  const lines = [
13
- "configHome: " + MINICODE_HOME + " (.env, agent.config.json)",
13
+ "configHome: " + MINICODE_HOME + " (.env)",
14
14
  "workspaceRoot: " + config.workspaceRoot,
15
15
  "modelProvider: " + config.modelProvider,
16
16
  "model: " + config.model,
@@ -46,9 +46,14 @@ export function formatConfigForDisplay(config) {
46
46
  */
47
47
  export function getConfigMissing(config) {
48
48
  const missing = [];
49
+ const isOpenRouter = config.modelProvider === "openai-compatible" &&
50
+ config.openAiBaseUrl.includes("openrouter");
49
51
  if (!config.model) {
50
52
  missing.push("MODEL is not set");
51
53
  }
54
+ if (isOpenRouter && !config.openAiApiKey?.trim()) {
55
+ missing.push("OPENROUTER_API_KEY is not set");
56
+ }
52
57
  if (config.modelProvider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
53
58
  missing.push("ANTHROPIC_API_KEY is not set");
54
59
  }
@@ -63,8 +68,8 @@ export function getConfigSetupMessage(config) {
63
68
  "minicode is not configured yet. Missing:",
64
69
  ...missing.map((m) => ` - ${m}`),
65
70
  "",
66
- `Set these in ~/.minicode/.env or as environment variables.`,
67
- `Edit ~/.minicode/agent.config.json for non-secret settings.`,
71
+ "Set these in ~/.minicode/.env or as environment variables.",
72
+ "Editable runtime defaults set through the UI or /config are saved back to ~/.minicode/.env.",
68
73
  "",
69
74
  "Example ~/.minicode/.env for a local model:",
70
75
  " MODEL_PROVIDER=openai-compatible",
@@ -117,20 +122,6 @@ function parseBoolean(value, fallback) {
117
122
  }
118
123
  return fallback;
119
124
  }
120
- export async function loadConfigFile(configPath) {
121
- try {
122
- await access(configPath);
123
- }
124
- catch {
125
- return {};
126
- }
127
- const file = await readFile(configPath, "utf8");
128
- const parsed = JSON.parse(file);
129
- if (!parsed || typeof parsed !== "object") {
130
- return {};
131
- }
132
- return parsed;
133
- }
134
125
  async function loadDotenvFile(envPath) {
135
126
  try {
136
127
  const file = await readFile(envPath, "utf8");
@@ -188,6 +179,24 @@ function parseUserDenylist(patterns) {
188
179
  }
189
180
  return denylist;
190
181
  }
182
+ function parseCommandDenylistEnv(value) {
183
+ if (!value?.trim()) {
184
+ return [];
185
+ }
186
+ const trimmed = value.trim();
187
+ if (trimmed.startsWith("[")) {
188
+ try {
189
+ const parsed = JSON.parse(trimmed);
190
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
191
+ return parseUserDenylist(parsed);
192
+ }
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ }
198
+ return parseUserDenylist(trimmed.split(",").map((pattern) => pattern.trim()).filter(Boolean));
199
+ }
191
200
  function parseModelProvider(value) {
192
201
  const normalized = value?.trim().toLowerCase();
193
202
  if (normalized === "openai-compatible" ||
@@ -198,74 +207,50 @@ function parseModelProvider(value) {
198
207
  }
199
208
  return "anthropic";
200
209
  }
201
- const DEFAULT_CONFIG_CONTENT = `{
202
- "modelProvider": "openai-compatible",
203
- "model": "",
204
- "openAiBaseUrl": "http://localhost:1234/v1",
205
- "maxSteps": 50,
206
- "maxTokens": 4096,
207
- "maxContextTokens": 32000
208
- }
209
- `;
210
210
  async function ensureMinicodeHome(minicodeHome) {
211
211
  await mkdir(minicodeHome, { recursive: true });
212
- const configPath = path.join(minicodeHome, "agent.config.json");
213
- try {
214
- await access(configPath);
215
- }
216
- catch {
217
- await writeFile(configPath, DEFAULT_CONFIG_CONTENT, "utf8");
218
- }
219
212
  }
220
213
  export async function loadAgentConfig(cwd = process.cwd(), options = {}) {
221
214
  const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
222
215
  await ensureMinicodeHome(minicodeHome);
223
- const homeConfigPath = path.join(minicodeHome, "agent.config.json");
224
- const fileConfig = await loadConfigFile(homeConfigPath);
225
216
  const env = (await resolveConfigEnv({ minicodeHome })).values;
226
- const rawWorkspaceRoot = env.WORKSPACE_ROOT ?? fileConfig.workspaceRoot ?? cwd;
217
+ const rawWorkspaceRoot = env.WORKSPACE_ROOT ?? cwd;
227
218
  const workspaceRoot = path.resolve(cwd, rawWorkspaceRoot);
228
219
  const commandDenylist = [
229
220
  ...DEFAULT_COMMAND_DENYLIST,
230
- ...parseUserDenylist(fileConfig.commandDenylist),
221
+ ...parseCommandDenylistEnv(env.COMMAND_DENYLIST),
231
222
  ];
232
- const rawBaseUrl = env.OPENAI_BASE_URL ??
233
- fileConfig.openAiBaseUrl ??
234
- "http://localhost:1234/v1";
223
+ const rawBaseUrl = env.OPENAI_BASE_URL ?? "http://localhost:1234/v1";
235
224
  const isOpenRouter = rawBaseUrl.includes("openrouter");
236
225
  const openAiApiKey = isOpenRouter
237
- ? (env.OPENROUTER_API_KEY ??
238
- env.OPENAI_API_KEY ??
239
- fileConfig.openAiApiKey)
240
- : (env.OPENAI_API_KEY ?? fileConfig.openAiApiKey);
226
+ ? (env.OPENROUTER_API_KEY ?? env.OPENAI_API_KEY)
227
+ : env.OPENAI_API_KEY;
241
228
  return {
242
- modelProvider: parseModelProvider(env.MODEL_PROVIDER ?? fileConfig.modelProvider ?? "openai-compatible"),
243
- model: env.MODEL ??
244
- fileConfig.model ??
245
- "",
246
- maxSteps: parseNumber(env.MAX_STEPS, fileConfig.maxSteps ?? 50),
247
- maxTokens: parseNumber(env.MAX_TOKENS, fileConfig.maxTokens ?? 4096),
248
- maxContextTokens: parseNumber(env.MAX_CONTEXT_TOKENS, fileConfig.maxContextTokens ?? 32_000),
229
+ modelProvider: parseModelProvider(env.MODEL_PROVIDER ?? "openai-compatible"),
230
+ model: env.MODEL ?? "",
231
+ maxSteps: parseNumber(env.MAX_STEPS, 50),
232
+ maxTokens: parseNumber(env.MAX_TOKENS, 4096),
233
+ maxContextTokens: parseNumber(env.MAX_CONTEXT_TOKENS, 32_000),
249
234
  workspaceRoot,
250
- commandTimeoutMs: parseNumber(env.COMMAND_TIMEOUT_MS, fileConfig.commandTimeout ?? 30_000),
251
- maxFileSizeBytes: parseNumber(env.MAX_FILE_SIZE_BYTES, fileConfig.maxFileSizeBytes ?? 1_000_000),
235
+ commandTimeoutMs: parseNumber(env.COMMAND_TIMEOUT_MS, 30_000),
236
+ maxFileSizeBytes: parseNumber(env.MAX_FILE_SIZE_BYTES, 1_000_000),
252
237
  commandDenylist,
253
- confirmDestructive: parseBoolean(env.CONFIRM_DESTRUCTIVE, fileConfig.confirmDestructive ?? true),
254
- keepRecentMessages: parseNumber(env.KEEP_RECENT_MESSAGES, fileConfig.keepRecentMessages ?? 12),
255
- loopDetectionWindow: parseNumber(env.LOOP_DETECTION_WINDOW, fileConfig.loopDetectionWindow ?? 6),
256
- maxToolOutputChars: parseNumber(env.MAX_TOOL_OUTPUT_CHARS, fileConfig.maxToolOutputChars ?? 8_000),
238
+ confirmDestructive: parseBoolean(env.CONFIRM_DESTRUCTIVE, true),
239
+ keepRecentMessages: parseNumber(env.KEEP_RECENT_MESSAGES, 12),
240
+ loopDetectionWindow: parseNumber(env.LOOP_DETECTION_WINDOW, 6),
241
+ maxToolOutputChars: parseNumber(env.MAX_TOOL_OUTPUT_CHARS, 8_000),
257
242
  openAiBaseUrl: rawBaseUrl,
258
243
  ...(openAiApiKey !== undefined ? { openAiApiKey } : {}),
259
- enableFileReadDedup: parseBoolean(env.ENABLE_FILE_READ_DEDUP, fileConfig.enableFileReadDedup ?? true),
260
- enableAdaptiveKeepRecent: parseBoolean(env.ENABLE_ADAPTIVE_KEEP_RECENT, fileConfig.enableAdaptiveKeepRecent ?? true),
261
- enableToolOutputTruncation: parseBoolean(env.ENABLE_TOOL_OUTPUT_TRUNCATION, fileConfig.enableToolOutputTruncation ?? true),
262
- compactionThreshold: parseNumber(env.COMPACTION_THRESHOLD, fileConfig.compactionThreshold ?? 0.8),
263
- ...(env.COMPACTION_MODEL ?? fileConfig.compactionModel
264
- ? { compactionModel: env.COMPACTION_MODEL ?? fileConfig.compactionModel }
244
+ enableFileReadDedup: parseBoolean(env.ENABLE_FILE_READ_DEDUP, true),
245
+ enableAdaptiveKeepRecent: parseBoolean(env.ENABLE_ADAPTIVE_KEEP_RECENT, true),
246
+ enableToolOutputTruncation: parseBoolean(env.ENABLE_TOOL_OUTPUT_TRUNCATION, true),
247
+ compactionThreshold: parseNumber(env.COMPACTION_THRESHOLD, 0.8),
248
+ ...(env.COMPACTION_MODEL
249
+ ? { compactionModel: env.COMPACTION_MODEL }
265
250
  : {}),
266
- enableDynamicPrompt: parseBoolean(env.ENABLE_DYNAMIC_PROMPT, fileConfig.enableDynamicPrompt ?? true),
251
+ enableDynamicPrompt: parseBoolean(env.ENABLE_DYNAMIC_PROMPT, true),
267
252
  ...(() => {
268
- const effort = parseReasoningEffort(env.REASONING_EFFORT ?? fileConfig.reasoningEffort);
253
+ const effort = parseReasoningEffort(env.REASONING_EFFORT);
269
254
  return effort ? { reasoningEffort: effort } : {};
270
255
  })(),
271
256
  };
@@ -1,6 +1,5 @@
1
- import { mkdir, rm, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { loadConfigFile, MINICODE_HOME, resolveConfigEnv, } from "./config.js";
1
+ import { MINICODE_HOME, resolveConfigEnv, } from "./config.js";
2
+ import { getHomeEnvPath, loadHomeEnvValues, upsertHomeEnvValues } from "./home-env.js";
4
3
  const REASONING_VALUES = [
5
4
  "xhigh",
6
5
  "high",
@@ -12,7 +11,6 @@ const REASONING_VALUES = [
12
11
  export const EDITABLE_CONFIG_DEFINITIONS = [
13
12
  {
14
13
  key: "modelProvider",
15
- fileKey: "modelProvider",
16
14
  envVar: "MODEL_PROVIDER",
17
15
  type: "enum",
18
16
  values: ["anthropic", "openai-compatible"],
@@ -20,119 +18,102 @@ export const EDITABLE_CONFIG_DEFINITIONS = [
20
18
  },
21
19
  {
22
20
  key: "model",
23
- fileKey: "model",
24
21
  envVar: "MODEL",
25
22
  type: "string",
26
23
  description: "Default model id for new sessions",
27
24
  },
28
25
  {
29
26
  key: "maxSteps",
30
- fileKey: "maxSteps",
31
27
  envVar: "MAX_STEPS",
32
28
  type: "number",
33
29
  description: "Turn call limit before the agent pauses and waits for another prompt",
34
30
  },
35
31
  {
36
32
  key: "maxTokens",
37
- fileKey: "maxTokens",
38
33
  envVar: "MAX_TOKENS",
39
34
  type: "number",
40
35
  description: "Maximum completion tokens per model response",
41
36
  },
42
37
  {
43
38
  key: "maxContextTokens",
44
- fileKey: "maxContextTokens",
45
39
  envVar: "MAX_CONTEXT_TOKENS",
46
40
  type: "number",
47
41
  description: "Estimated prompt-context budget before compaction",
48
42
  },
49
43
  {
50
44
  key: "commandTimeoutMs",
51
- fileKey: "commandTimeout",
52
45
  envVar: "COMMAND_TIMEOUT_MS",
53
46
  type: "number",
54
47
  description: "Shell command timeout in milliseconds",
55
48
  },
56
49
  {
57
50
  key: "maxFileSizeBytes",
58
- fileKey: "maxFileSizeBytes",
59
51
  envVar: "MAX_FILE_SIZE_BYTES",
60
52
  type: "number",
61
53
  description: "Maximum file size allowed for read/edit tools",
62
54
  },
63
55
  {
64
56
  key: "confirmDestructive",
65
- fileKey: "confirmDestructive",
66
57
  envVar: "CONFIRM_DESTRUCTIVE",
67
58
  type: "boolean",
68
59
  description: "Whether destructive commands require confirmation",
69
60
  },
70
61
  {
71
62
  key: "keepRecentMessages",
72
- fileKey: "keepRecentMessages",
73
63
  envVar: "KEEP_RECENT_MESSAGES",
74
64
  type: "number",
75
65
  description: "Recent messages preserved when trimming session history",
76
66
  },
77
67
  {
78
68
  key: "loopDetectionWindow",
79
- fileKey: "loopDetectionWindow",
80
69
  envVar: "LOOP_DETECTION_WINDOW",
81
70
  type: "number",
82
71
  description: "Window size for repeated-tool-call loop detection",
83
72
  },
84
73
  {
85
74
  key: "maxToolOutputChars",
86
- fileKey: "maxToolOutputChars",
87
75
  envVar: "MAX_TOOL_OUTPUT_CHARS",
88
76
  type: "number",
89
77
  description: "Maximum tool output retained before truncation",
90
78
  },
91
79
  {
92
80
  key: "openAiBaseUrl",
93
- fileKey: "openAiBaseUrl",
94
81
  envVar: "OPENAI_BASE_URL",
95
82
  type: "string",
96
83
  description: "Base URL for OpenAI-compatible providers",
97
84
  },
98
85
  {
99
86
  key: "enableFileReadDedup",
100
- fileKey: "enableFileReadDedup",
101
87
  envVar: "ENABLE_FILE_READ_DEDUP",
102
88
  type: "boolean",
103
89
  description: "Deduplicate repeated file reads in prompt context",
104
90
  },
105
91
  {
106
92
  key: "enableAdaptiveKeepRecent",
107
- fileKey: "enableAdaptiveKeepRecent",
108
93
  envVar: "ENABLE_ADAPTIVE_KEEP_RECENT",
109
94
  type: "boolean",
110
95
  description: "Adjust recent-message retention based on context pressure",
111
96
  },
112
97
  {
113
98
  key: "enableToolOutputTruncation",
114
- fileKey: "enableToolOutputTruncation",
115
99
  envVar: "ENABLE_TOOL_OUTPUT_TRUNCATION",
116
100
  type: "boolean",
117
101
  description: "Truncate oversized tool output before storing it in session history",
118
102
  },
119
103
  {
120
104
  key: "compactionThreshold",
121
- fileKey: "compactionThreshold",
122
105
  envVar: "COMPACTION_THRESHOLD",
123
106
  type: "number",
124
107
  description: "Compaction threshold ratio used before a turn starts",
125
108
  },
126
109
  {
127
110
  key: "compactionModel",
128
- fileKey: "compactionModel",
129
111
  envVar: "COMPACTION_MODEL",
130
112
  type: "string",
131
113
  description: "Optional model id used for LLM-based compaction",
132
114
  },
133
115
  {
134
116
  key: "reasoningEffort",
135
- fileKey: "reasoningEffort",
136
117
  envVar: "REASONING_EFFORT",
137
118
  type: "enum",
138
119
  values: REASONING_VALUES,
@@ -140,7 +121,6 @@ export const EDITABLE_CONFIG_DEFINITIONS = [
140
121
  },
141
122
  {
142
123
  key: "enableDynamicPrompt",
143
- fileKey: "enableDynamicPrompt",
144
124
  envVar: "ENABLE_DYNAMIC_PROMPT",
145
125
  type: "boolean",
146
126
  description: "Toggle project-aware dynamic prompt generation",
@@ -194,9 +174,6 @@ function normalizePersistedValue(value) {
194
174
  }
195
175
  return null;
196
176
  }
197
- function isEmptyConfigFile(config) {
198
- return Object.keys(config).length === 0;
199
- }
200
177
  export function listEditableConfigDefinitions() {
201
178
  return EDITABLE_CONFIG_DEFINITIONS;
202
179
  }
@@ -223,10 +200,25 @@ export function formatPersistedConfigValue(value) {
223
200
  return String(value);
224
201
  }
225
202
  export function getGlobalConfigPath(minicodeHome = MINICODE_HOME) {
226
- return path.join(minicodeHome, "agent.config.json");
203
+ return getHomeEnvPath(minicodeHome);
227
204
  }
228
205
  export async function loadPersistedConfig(minicodeHome = MINICODE_HOME) {
229
- return loadConfigFile(getGlobalConfigPath(minicodeHome));
206
+ return loadHomeEnvValues(minicodeHome);
207
+ }
208
+ function readPersistedEnvValue(definition, persisted) {
209
+ const rawValue = persisted[definition.envVar];
210
+ if (rawValue === undefined) {
211
+ return null;
212
+ }
213
+ try {
214
+ return parseEditableValue(definition, rawValue);
215
+ }
216
+ catch {
217
+ return rawValue;
218
+ }
219
+ }
220
+ function serializeEditableValue(value) {
221
+ return String(value);
230
222
  }
231
223
  export async function buildStructuredConfigPayload(config, minicodeHome = MINICODE_HOME) {
232
224
  const configPath = getGlobalConfigPath(minicodeHome);
@@ -235,10 +227,11 @@ export async function buildStructuredConfigPayload(config, minicodeHome = MINICO
235
227
  return {
236
228
  configPath,
237
229
  entries: EDITABLE_CONFIG_DEFINITIONS.map((definition) => {
238
- const envValue = env.values[definition.envVar];
239
- const envSource = env.sources[definition.envVar] ?? null;
240
- const envSourcePath = envSource === "home-dotenv"
241
- ? env.homeEnvPath
230
+ const envSource = env.sources[definition.envVar] === "process"
231
+ ? "process"
232
+ : null;
233
+ const envValue = envSource === "process"
234
+ ? (env.values[definition.envVar] ?? null)
242
235
  : null;
243
236
  return {
244
237
  key: definition.key,
@@ -247,11 +240,11 @@ export async function buildStructuredConfigPayload(config, minicodeHome = MINICO
247
240
  envVar: definition.envVar,
248
241
  ...(definition.values ? { values: definition.values } : {}),
249
242
  effectiveValue: normalizePersistedValue(config[definition.key]),
250
- persistedValue: normalizePersistedValue(persisted[definition.fileKey]),
251
- envValue: envValue ?? null,
243
+ persistedValue: readPersistedEnvValue(definition, persisted),
244
+ envValue,
252
245
  envSource,
253
- envSourcePath,
254
- overriddenByEnv: envValue !== undefined,
246
+ envSourcePath: envSource === "process" ? null : null,
247
+ overriddenByEnv: envSource === "process",
255
248
  };
256
249
  }),
257
250
  };
@@ -260,26 +253,25 @@ export async function setPersistedConfigValue(options) {
260
253
  const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
261
254
  const definition = getEditableConfigDefinition(options.key);
262
255
  const configPath = getGlobalConfigPath(minicodeHome);
263
- const nextFile = await loadConfigFile(configPath);
264
256
  const storedValue = parseEditableValue(definition, options.rawValue);
265
- nextFile[definition.fileKey] = storedValue;
266
- await mkdir(path.dirname(configPath), { recursive: true });
267
- await writeFile(configPath, JSON.stringify(nextFile, null, 2) + "\n", "utf8");
257
+ await upsertHomeEnvValues({
258
+ minicodeHome,
259
+ values: {
260
+ [definition.envVar]: serializeEditableValue(storedValue),
261
+ },
262
+ });
268
263
  return { path: configPath, storedValue };
269
264
  }
270
265
  export async function unsetPersistedConfigValue(options) {
271
266
  const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
272
267
  const definition = getEditableConfigDefinition(options.key);
273
268
  const configPath = getGlobalConfigPath(minicodeHome);
274
- const nextFile = await loadConfigFile(configPath);
275
- delete nextFile[definition.fileKey];
276
- if (isEmptyConfigFile(nextFile)) {
277
- await rm(configPath, { force: true });
278
- }
279
- else {
280
- await mkdir(path.dirname(configPath), { recursive: true });
281
- await writeFile(configPath, JSON.stringify(nextFile, null, 2) + "\n", "utf8");
282
- }
269
+ await upsertHomeEnvValues({
270
+ minicodeHome,
271
+ values: {
272
+ [definition.envVar]: null,
273
+ },
274
+ });
283
275
  return { path: configPath };
284
276
  }
285
277
  export async function applyPersistedConfigUpdates(options) {
@@ -291,22 +283,22 @@ export async function applyPersistedConfigUpdates(options) {
291
283
  return { key: rawKey, value };
292
284
  });
293
285
  const saved = [];
286
+ const envUpdates = {};
294
287
  for (const item of planned) {
288
+ const definition = getEditableConfigDefinition(item.key);
295
289
  if (item.value === null) {
296
- await unsetPersistedConfigValue({
297
- key: item.key,
298
- minicodeHome,
299
- });
290
+ envUpdates[definition.envVar] = null;
300
291
  saved.push({ key: item.key, value: null });
301
292
  continue;
302
293
  }
303
- await setPersistedConfigValue({
304
- key: item.key,
305
- rawValue: String(item.value),
306
- minicodeHome,
307
- });
308
- saved.push({ key: item.key, value: item.value });
294
+ const storedValue = parseEditableValue(definition, String(item.value));
295
+ envUpdates[definition.envVar] = serializeEditableValue(storedValue);
296
+ saved.push({ key: item.key, value: storedValue });
309
297
  }
298
+ await upsertHomeEnvValues({
299
+ minicodeHome,
300
+ values: envUpdates,
301
+ });
310
302
  return {
311
303
  path: getGlobalConfigPath(minicodeHome),
312
304
  saved,
@@ -0,0 +1,74 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import dotenv from "dotenv";
4
+ import { MINICODE_HOME } from "./config.js";
5
+ export function getHomeEnvPath(minicodeHome = MINICODE_HOME) {
6
+ return path.join(minicodeHome, ".env");
7
+ }
8
+ export async function loadHomeEnvValues(minicodeHome = MINICODE_HOME) {
9
+ const envPath = getHomeEnvPath(minicodeHome);
10
+ try {
11
+ const existing = await readFile(envPath, "utf8");
12
+ return dotenv.parse(existing);
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ function formatEnvValue(value) {
19
+ return value;
20
+ }
21
+ export async function upsertHomeEnvValues(options) {
22
+ const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
23
+ const envPath = getHomeEnvPath(minicodeHome);
24
+ await mkdir(path.dirname(envPath), { recursive: true });
25
+ let existing = "";
26
+ try {
27
+ existing = await readFile(envPath, "utf8");
28
+ }
29
+ catch {
30
+ existing = "";
31
+ }
32
+ const pending = new Map(Object.entries(options.values));
33
+ const managedKeys = new Set(pending.keys());
34
+ const assignmentPattern = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
35
+ const normalizedLines = existing === ""
36
+ ? []
37
+ : existing.split(/\r?\n/);
38
+ const nextLines = [];
39
+ const seen = new Set();
40
+ for (const line of normalizedLines) {
41
+ const match = line.match(assignmentPattern);
42
+ if (!match) {
43
+ nextLines.push(line);
44
+ continue;
45
+ }
46
+ const key = match[1];
47
+ if (!managedKeys.has(key)) {
48
+ nextLines.push(line);
49
+ continue;
50
+ }
51
+ if (seen.has(key)) {
52
+ continue;
53
+ }
54
+ const nextValue = pending.get(key);
55
+ if (nextValue !== null) {
56
+ nextLines.push(`${key}=${formatEnvValue(nextValue)}`);
57
+ }
58
+ seen.add(key);
59
+ pending.delete(key);
60
+ }
61
+ const pendingEntries = [...pending.entries()].filter((entry) => entry[1] !== null);
62
+ if (pendingEntries.length > 0 && nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") {
63
+ nextLines.push("");
64
+ }
65
+ for (const [key, value] of pendingEntries) {
66
+ nextLines.push(`${key}=${formatEnvValue(value)}`);
67
+ }
68
+ const fileContent = `${nextLines.join("\n").replace(/\n+$/, "")}\n`;
69
+ await writeFile(envPath, fileContent, "utf8");
70
+ return {
71
+ path: envPath,
72
+ updatedKeys: Object.keys(options.values),
73
+ };
74
+ }