@sean.holung/minicode 0.3.4 → 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.
Files changed (72) hide show
  1. package/README.md +25 -47
  2. package/dist/scripts/run-benchmarks.js +73 -28
  3. package/dist/src/agent/config.js +51 -66
  4. package/dist/src/agent/editable-config.js +50 -58
  5. package/dist/src/agent/home-env.js +74 -0
  6. package/dist/src/benchmark/runner.js +142 -59
  7. package/dist/src/cli/config-slash-command.js +15 -13
  8. package/dist/src/indexer/project-index.js +49 -13
  9. package/dist/src/serve/agent-bridge.js +99 -31
  10. package/dist/src/serve/mcp-server.js +70 -21
  11. package/dist/src/serve/server.js +198 -8
  12. package/dist/src/session/session-preview.js +14 -0
  13. package/dist/src/shared/graph-search.js +80 -0
  14. package/dist/src/shared/graph-selection.js +40 -0
  15. package/dist/src/shared/graph-symbols.js +82 -0
  16. package/dist/src/shared/symbol-resolution.js +33 -0
  17. package/dist/src/tools/find-path.js +15 -6
  18. package/dist/src/tools/find-references.js +7 -2
  19. package/dist/src/tools/get-dependencies.js +8 -3
  20. package/dist/src/tools/read-symbol.js +9 -3
  21. package/dist/src/tools/registry.js +4 -1
  22. package/dist/src/tools/search-code-map.js +18 -3
  23. package/dist/src/web/app.js +646 -87
  24. package/dist/src/web/index.html +68 -6
  25. package/dist/src/web/style.css +208 -1
  26. package/dist/tests/benchmark-harness.test.js +100 -0
  27. package/dist/tests/config-api.test.js +5 -5
  28. package/dist/tests/config-integration.test.js +130 -56
  29. package/dist/tests/config-slash-command.test.js +12 -11
  30. package/dist/tests/config.test.js +12 -4
  31. package/dist/tests/editable-config.test.js +15 -12
  32. package/dist/tests/file-tools.test.js +34 -1
  33. package/dist/tests/find-path.test.js +43 -2
  34. package/dist/tests/find-references.test.js +49 -0
  35. package/dist/tests/get-dependencies.test.js +23 -0
  36. package/dist/tests/graph-onboarding.test.js +10 -1
  37. package/dist/tests/graph-search.test.js +66 -0
  38. package/dist/tests/graph-selection.test.js +58 -0
  39. package/dist/tests/graph-symbols.test.js +45 -0
  40. package/dist/tests/home-env.test.js +56 -0
  41. package/dist/tests/indexer.test.js +6 -0
  42. package/dist/tests/read-symbol.test.js +35 -0
  43. package/dist/tests/request-tracker.test.js +15 -0
  44. package/dist/tests/run-benchmarks.test.js +117 -33
  45. package/dist/tests/search-code-map.test.js +2 -0
  46. package/dist/tests/serve.integration.test.js +338 -9
  47. package/dist/tests/session-preview.test.js +56 -0
  48. package/dist/tests/session-ui.test.js +4 -0
  49. package/dist/tests/settings-ui.test.js +18 -0
  50. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  51. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +2 -1
  52. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  53. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +1 -1
  54. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  55. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  56. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +3 -0
  57. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  58. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts +3 -0
  59. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts.map +1 -1
  60. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js +4 -1
  61. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js.map +1 -1
  62. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts +11 -1
  63. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  64. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +4 -1
  65. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  66. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.d.ts.map +1 -1
  67. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js +16 -8
  68. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js.map +1 -1
  69. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js +19 -2
  70. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js.map +1 -1
  71. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  72. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Integration tests for the config system.
3
3
  *
4
- * Tests the full config resolution chain: agent.config.json → ~/.minicode/.env → shell env vars,
5
- * getConfigSetupMessage / getConfigMissing, ensureMinicodeHome, the /api/status needsSetup flag,
4
+ * Tests the current config resolution chain: ~/.minicode/.env → shell env vars,
5
+ * getConfigSetupMessage / getConfigMissing, minicode home bootstrapping, the /api/status needsSetup flag,
6
6
  * and AgentBridge graceful degradation when model client cannot initialize.
7
7
  */
8
8
  import assert from "node:assert/strict";
@@ -11,7 +11,7 @@ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { afterEach, describe, test } from "node:test";
14
- import { loadAgentConfig, resolveConfigEnv, getConfigSetupMessage, getConfigMissing, loadConfigFile, } from "../src/agent/config.js";
14
+ import { loadAgentConfig, resolveConfigEnv, getConfigSetupMessage, getConfigMissing, } from "../src/agent/config.js";
15
15
  import { buildStructuredConfigPayload } from "../src/agent/editable-config.js";
16
16
  import { createRequestHandler } from "../src/serve/server.js";
17
17
  import { AgentBridge } from "../src/serve/agent-bridge.js";
@@ -25,14 +25,46 @@ afterEach(async () => {
25
25
  await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
26
26
  });
27
27
  /** Create an isolated minicode home directory with optional config and .env files. */
28
+ function serializeConfigEnv(config) {
29
+ const lines = [];
30
+ const push = (key, value) => {
31
+ if (value === undefined || value === null) {
32
+ return;
33
+ }
34
+ lines.push(`${key}=${String(value)}`);
35
+ };
36
+ push("MODEL_PROVIDER", config.modelProvider);
37
+ push("MODEL", config.model);
38
+ push("MAX_STEPS", config.maxSteps);
39
+ push("MAX_TOKENS", config.maxTokens);
40
+ push("MAX_CONTEXT_TOKENS", config.maxContextTokens);
41
+ push("WORKSPACE_ROOT", config.workspaceRoot);
42
+ push("COMMAND_TIMEOUT_MS", config.commandTimeout);
43
+ if (Array.isArray(config.commandDenylist)) {
44
+ lines.push(`COMMAND_DENYLIST=${JSON.stringify(config.commandDenylist)}`);
45
+ }
46
+ push("CONFIRM_DESTRUCTIVE", config.confirmDestructive);
47
+ push("MAX_FILE_SIZE_BYTES", config.maxFileSizeBytes);
48
+ push("KEEP_RECENT_MESSAGES", config.keepRecentMessages);
49
+ push("LOOP_DETECTION_WINDOW", config.loopDetectionWindow);
50
+ push("MAX_TOOL_OUTPUT_CHARS", config.maxToolOutputChars);
51
+ push("OPENAI_BASE_URL", config.openAiBaseUrl);
52
+ push("OPENAI_API_KEY", config.openAiApiKey);
53
+ push("ENABLE_FILE_READ_DEDUP", config.enableFileReadDedup);
54
+ push("ENABLE_ADAPTIVE_KEEP_RECENT", config.enableAdaptiveKeepRecent);
55
+ push("ENABLE_TOOL_OUTPUT_TRUNCATION", config.enableToolOutputTruncation);
56
+ push("COMPACTION_THRESHOLD", config.compactionThreshold);
57
+ push("COMPACTION_MODEL", config.compactionModel);
58
+ push("REASONING_EFFORT", config.reasoningEffort);
59
+ push("ENABLE_DYNAMIC_PROMPT", config.enableDynamicPrompt);
60
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
61
+ }
28
62
  async function createTestHome(options = {}) {
29
63
  const home = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
30
64
  tempDirs.push(home);
31
- if (options.config) {
32
- await writeFile(path.join(home, "agent.config.json"), JSON.stringify(options.config, null, 2) + "\n", "utf8");
33
- }
34
- if (options.dotenv !== undefined) {
35
- await writeFile(path.join(home, ".env"), options.dotenv, "utf8");
65
+ const envContent = `${options.config ? serializeConfigEnv(options.config) : ""}${options.dotenv ?? ""}`;
66
+ if (envContent.length > 0) {
67
+ await writeFile(path.join(home, ".env"), envContent, "utf8");
36
68
  }
37
69
  return home;
38
70
  }
@@ -102,27 +134,25 @@ function startServer(bridge, options = {}) {
102
134
  });
103
135
  }
104
136
  // ═══════════════════════════════════════════════════════════════════
105
- // Config file loading
137
+ // Home env loading via loadAgentConfig
106
138
  // ═══════════════════════════════════════════════════════════════════
107
- describe("loadConfigFile", () => {
108
- test("returns empty object when file does not exist", async () => {
109
- const result = await loadConfigFile("/tmp/nonexistent-minicode-test/config.json");
110
- assert.deepEqual(result, {});
111
- });
112
- test("parses valid JSON config file", async () => {
139
+ describe("loadAgentConfig home env", () => {
140
+ test("uses ~/.minicode/.env values when present", async () => {
113
141
  const home = await createTestHome({
114
- config: { model: "test-model", maxSteps: 25 },
142
+ dotenv: "MODEL=test-model\nMAX_STEPS=25\n",
143
+ });
144
+ await withEnv({ MODEL: undefined, MAX_STEPS: undefined }, async () => {
145
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
146
+ assert.equal(config.model, "test-model");
147
+ assert.equal(config.maxSteps, 25);
115
148
  });
116
- const result = await loadConfigFile(path.join(home, "agent.config.json"));
117
- assert.equal(result.model, "test-model");
118
- assert.equal(result.maxSteps, 25);
119
149
  });
120
150
  });
121
151
  // ═══════════════════════════════════════════════════════════════════
122
- // ensureMinicodeHome
152
+ // minicode home bootstrapping
123
153
  // ═══════════════════════════════════════════════════════════════════
124
154
  describe("ensureMinicodeHome (via loadAgentConfig)", () => {
125
- test("creates directory and starter config when home does not exist", async () => {
155
+ test("creates the minicode home directory when it does not exist", async () => {
126
156
  const base = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
127
157
  tempDirs.push(base);
128
158
  const minicodeHome = path.join(base, "fresh-home");
@@ -131,22 +161,18 @@ describe("ensureMinicodeHome (via loadAgentConfig)", () => {
131
161
  });
132
162
  // Directory should exist
133
163
  await access(minicodeHome);
134
- // Starter config should have been written
135
- const content = JSON.parse(await readFile(path.join(minicodeHome, "agent.config.json"), "utf8"));
136
- assert.equal(content.model, "");
137
- assert.equal(content.modelProvider, "openai-compatible");
138
- assert.equal(content.openAiBaseUrl, "http://localhost:1234/v1");
164
+ await assert.rejects(access(path.join(minicodeHome, "agent.config.json")));
139
165
  });
140
- test("does not overwrite existing config", async () => {
166
+ test("does not overwrite an existing home .env file", async () => {
141
167
  const home = await createTestHome({
142
- config: { model: "my-model", maxSteps: 99 },
168
+ dotenv: "MODEL=my-model\nMAX_STEPS=99\n",
143
169
  });
144
170
  await withEnv({ MODEL: undefined }, async () => {
145
171
  await loadAgentConfig("/tmp", { minicodeHome: home });
146
172
  });
147
- const content = JSON.parse(await readFile(path.join(home, "agent.config.json"), "utf8"));
148
- assert.equal(content.model, "my-model");
149
- assert.equal(content.maxSteps, 99);
173
+ const content = await readFile(path.join(home, ".env"), "utf8");
174
+ assert.match(content, /^MODEL=my-model$/m);
175
+ assert.match(content, /^MAX_STEPS=99$/m);
150
176
  });
151
177
  });
152
178
  // ═══════════════════════════════════════════════════════════════════
@@ -207,7 +233,7 @@ describe("resolveConfigEnv precedence", () => {
207
233
  // loadAgentConfig — full precedence chain
208
234
  // ═══════════════════════════════════════════════════════════════════
209
235
  describe("loadAgentConfig precedence", () => {
210
- test("config file value is used when no env override exists", async () => {
236
+ test("home .env value is used when no shell override exists", async () => {
211
237
  const home = await createTestHome({
212
238
  config: { model: "config-model", maxSteps: 42 },
213
239
  });
@@ -217,7 +243,7 @@ describe("loadAgentConfig precedence", () => {
217
243
  assert.equal(config.maxSteps, 42);
218
244
  });
219
245
  });
220
- test("home .env overrides config file", async () => {
246
+ test("later home .env entries override earlier persisted values", async () => {
221
247
  const home = await createTestHome({
222
248
  config: { model: "config-model", maxSteps: 42 },
223
249
  dotenv: "MODEL=dotenv-model\n",
@@ -225,11 +251,10 @@ describe("loadAgentConfig precedence", () => {
225
251
  await withEnv({ MODEL: undefined }, async () => {
226
252
  const config = await loadAgentConfig("/tmp", { minicodeHome: home });
227
253
  assert.equal(config.model, "dotenv-model");
228
- // maxSteps not in .env, so config file value is used
229
254
  assert.equal(config.maxSteps, 42);
230
255
  });
231
256
  });
232
- test("shell env overrides both config file and home .env", async () => {
257
+ test("shell env overrides home .env", async () => {
233
258
  const home = await createTestHome({
234
259
  config: { model: "config-model", maxSteps: 42 },
235
260
  dotenv: "MODEL=dotenv-model\nMAX_STEPS=77\n",
@@ -285,18 +310,13 @@ describe("loadAgentConfig precedence", () => {
285
310
  assert.equal(config.openAiApiKey, "sk-shell-key");
286
311
  });
287
312
  });
288
- test("OPENAI_API_KEY from config file is used as last resort", async () => {
313
+ test("OPENAI_API_KEY is unset when not provided in env", async () => {
289
314
  const home = await createTestHome({
290
- config: {
291
- modelProvider: "openai-compatible",
292
- model: "test-model",
293
- openAiBaseUrl: "http://localhost:1234/v1",
294
- openAiApiKey: "sk-file-key",
295
- },
315
+ dotenv: "MODEL_PROVIDER=openai-compatible\nMODEL=test-model\nOPENAI_BASE_URL=http://localhost:1234/v1\n",
296
316
  });
297
317
  await withEnv({ OPENAI_API_KEY: undefined, MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
298
318
  const config = await loadAgentConfig("/tmp", { minicodeHome: home });
299
- assert.equal(config.openAiApiKey, "sk-file-key");
319
+ assert.equal(config.openAiApiKey, undefined);
300
320
  });
301
321
  });
302
322
  test("OpenRouter base URL resolves OPENROUTER_API_KEY with fallback to OPENAI_API_KEY", async () => {
@@ -412,6 +432,26 @@ describe("getConfigMissing and getConfigSetupMessage", () => {
412
432
  assert.deepEqual(getConfigMissing(config), []);
413
433
  });
414
434
  });
435
+ test("returns OPENROUTER_API_KEY missing for OpenRouter without a key", () => {
436
+ const config = {
437
+ ...createTestAgentConfig("/tmp"),
438
+ modelProvider: "openai-compatible",
439
+ model: "google/gemini-2.5-flash",
440
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
441
+ };
442
+ const missing = getConfigMissing(config);
443
+ assert.ok(missing.some((m) => m.includes("OPENROUTER_API_KEY")));
444
+ });
445
+ test("does not require OPENROUTER_API_KEY when OpenRouter key is present in config", () => {
446
+ const config = {
447
+ ...createTestAgentConfig("/tmp"),
448
+ modelProvider: "openai-compatible",
449
+ model: "google/gemini-2.5-flash",
450
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
451
+ openAiApiKey: "sk-or-v1-test",
452
+ };
453
+ assert.deepEqual(getConfigMissing(config), []);
454
+ });
415
455
  test("can return multiple missing items simultaneously", async () => {
416
456
  await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
417
457
  const config = {
@@ -430,7 +470,7 @@ describe("getConfigMissing and getConfigSetupMessage", () => {
430
470
  const message = getConfigSetupMessage(config);
431
471
  assert.ok(message.includes("MODEL is not set"));
432
472
  assert.ok(message.includes("~/.minicode/.env"));
433
- assert.ok(message.includes("agent.config.json"));
473
+ assert.ok(message.includes("saved back to ~/.minicode/.env"));
434
474
  });
435
475
  });
436
476
  // ═══════════════════════════════════════════════════════════════════
@@ -504,6 +544,25 @@ describe("loadAgentConfig → getConfigMissing integration", () => {
504
544
  assert.ok(missing.some((m) => m.includes("ANTHROPIC_API_KEY")));
505
545
  });
506
546
  });
547
+ test("OpenRouter provider without API key reports missing", async () => {
548
+ const home = await createTestHome({
549
+ config: {
550
+ modelProvider: "openai-compatible",
551
+ model: "google/gemini-2.5-flash",
552
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
553
+ },
554
+ });
555
+ await withEnv({
556
+ OPENROUTER_API_KEY: undefined,
557
+ OPENAI_API_KEY: undefined,
558
+ MODEL: undefined,
559
+ MODEL_PROVIDER: undefined,
560
+ }, async () => {
561
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
562
+ const missing = getConfigMissing(config);
563
+ assert.ok(missing.some((m) => m.includes("OPENROUTER_API_KEY")));
564
+ });
565
+ });
507
566
  });
508
567
  // ═══════════════════════════════════════════════════════════════════
509
568
  // /api/status endpoint — needsSetup and missing fields
@@ -551,6 +610,20 @@ describe("/api/status needsSetup", () => {
551
610
  assert.ok(body.missing.some((m) => m.includes("ANTHROPIC_API_KEY")));
552
611
  });
553
612
  });
613
+ test("returns needsSetup: true for OpenRouter without API key", async () => {
614
+ const config = {
615
+ ...createTestAgentConfig("/tmp"),
616
+ modelProvider: "openai-compatible",
617
+ model: "google/gemini-2.5-flash",
618
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
619
+ };
620
+ const bridge = new ConfigurableBridge(config);
621
+ const base = await startServer(bridge);
622
+ const res = await fetch(`${base}/api/status`);
623
+ const body = await res.json();
624
+ assert.equal(body.needsSetup, true);
625
+ assert.ok(body.missing.some((m) => m.includes("OPENROUTER_API_KEY")));
626
+ });
554
627
  test("status endpoint includes model and provider info", async () => {
555
628
  const config = {
556
629
  ...createTestAgentConfig("/tmp"),
@@ -600,7 +673,7 @@ describe("buildStructuredConfigPayload env source tracking", () => {
600
673
  assert.equal(modelEntry.envSource, "process");
601
674
  });
602
675
  });
603
- test("reports env override from home-dotenv", async () => {
676
+ test("treats ~/.minicode/.env as persisted settings rather than an override", async () => {
604
677
  const home = await createTestHome({
605
678
  config: { model: "file-model" },
606
679
  dotenv: "MAX_STEPS=88\n",
@@ -609,13 +682,14 @@ describe("buildStructuredConfigPayload env source tracking", () => {
609
682
  const config = await loadAgentConfig("/tmp", { minicodeHome: home });
610
683
  const payload = await buildStructuredConfigPayload(config, home);
611
684
  const maxStepsEntry = payload.entries.find((e) => e.key === "maxSteps");
612
- assert.equal(maxStepsEntry.overriddenByEnv, true);
613
- assert.equal(maxStepsEntry.envValue, "88");
614
- assert.equal(maxStepsEntry.envSource, "home-dotenv");
615
- assert.equal(maxStepsEntry.envSourcePath, path.join(home, ".env"));
685
+ assert.equal(maxStepsEntry.overriddenByEnv, false);
686
+ assert.equal(maxStepsEntry.persistedValue, 88);
687
+ assert.equal(maxStepsEntry.envValue, null);
688
+ assert.equal(maxStepsEntry.envSource, null);
689
+ assert.equal(maxStepsEntry.envSourcePath, null);
616
690
  });
617
691
  });
618
- test("reports no env override when value only in config file", async () => {
692
+ test("shows home .env values as both effective and persisted", async () => {
619
693
  const home = await createTestHome({
620
694
  config: { maxSteps: 42 },
621
695
  });
@@ -624,6 +698,7 @@ describe("buildStructuredConfigPayload env source tracking", () => {
624
698
  const payload = await buildStructuredConfigPayload(config, home);
625
699
  const maxStepsEntry = payload.entries.find((e) => e.key === "maxSteps");
626
700
  assert.equal(maxStepsEntry.overriddenByEnv, false);
701
+ assert.equal(maxStepsEntry.effectiveValue, 42);
627
702
  assert.equal(maxStepsEntry.persistedValue, 42);
628
703
  });
629
704
  });
@@ -643,7 +718,7 @@ describe("buildStructuredConfigPayload env source tracking", () => {
643
718
  // Realistic user scenario tests
644
719
  // ═══════════════════════════════════════════════════════════════════
645
720
  describe("realistic user scenarios", () => {
646
- test("OpenRouter setup: config file + shell OPENAI_API_KEY → no setup needed", async () => {
721
+ test("OpenRouter setup: home .env + shell OPENAI_API_KEY → no setup needed", async () => {
647
722
  // User has modelProvider and openAiBaseUrl in config, OPENAI_API_KEY in shell
648
723
  const home = await createTestHome({
649
724
  config: {
@@ -695,7 +770,7 @@ describe("realistic user scenarios", () => {
695
770
  assert.ok(missing[0].includes("MODEL"));
696
771
  });
697
772
  });
698
- test("local LM Studio setup: only config file, no env vars needed", async () => {
773
+ test("local LM Studio setup: only home .env, no shell env vars needed", async () => {
699
774
  const home = await createTestHome({
700
775
  config: {
701
776
  modelProvider: "openai-compatible",
@@ -733,15 +808,14 @@ describe("realistic user scenarios", () => {
733
808
  assert.deepEqual(getConfigMissing(config), []);
734
809
  });
735
810
  });
736
- test("fresh install: auto-created config triggers setup overlay", async () => {
811
+ test("fresh install: missing model still triggers setup overlay", async () => {
737
812
  const base = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
738
813
  tempDirs.push(base);
739
814
  const minicodeHome = path.join(base, "new-home");
740
815
  await withEnv({ MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
741
816
  const config = await loadAgentConfig("/tmp", { minicodeHome });
742
- // Should have auto-created the directory and starter config
743
- const fileContent = await readFile(path.join(minicodeHome, "agent.config.json"), "utf8");
744
- assert.ok(fileContent.includes('"model": ""'));
817
+ await access(minicodeHome);
818
+ await assert.rejects(access(path.join(minicodeHome, "agent.config.json")));
745
819
  // Empty model → needs setup
746
820
  assert.equal(config.model, "");
747
821
  const missing = getConfigMissing(config);
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { access, mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { afterEach, test } from "node:test";
@@ -18,14 +18,15 @@ test("setPersistedConfigValue writes mapped keys and unset removes empty config
18
18
  key: "commandTimeoutMs",
19
19
  rawValue: "45000",
20
20
  });
21
- assert.equal(setResult.path, path.join(home, "agent.config.json"));
22
- const file = JSON.parse(await readFile(setResult.path, "utf8"));
23
- assert.equal(file.commandTimeout, 45000);
21
+ assert.equal(setResult.path, path.join(home, ".env"));
22
+ const file = await readFile(setResult.path, "utf8");
23
+ assert.match(file, /^COMMAND_TIMEOUT_MS=45000$/m);
24
24
  await unsetPersistedConfigValue({
25
25
  minicodeHome: home,
26
26
  key: "commandTimeoutMs",
27
27
  });
28
- await assert.rejects(access(setResult.path));
28
+ const updated = await readFile(setResult.path, "utf8");
29
+ assert.doesNotMatch(updated, /^COMMAND_TIMEOUT_MS=/m);
29
30
  });
30
31
  test("handleConfigSlashCommand persists config and reports env overrides", async () => {
31
32
  const home = await mkdtemp(path.join(os.tmpdir(), "minicode-config-"));
@@ -41,9 +42,9 @@ test("handleConfigSlashCommand persists config and reports env overrides", async
41
42
  });
42
43
  assert.equal(result.handled, true);
43
44
  assert.match(result.message ?? "", /Saved config: maxSteps = 64/);
44
- assert.match(result.message ?? "", /MAX_STEPS is currently set/);
45
- const persisted = JSON.parse(await readFile(path.join(home, "agent.config.json"), "utf8"));
46
- assert.equal(persisted.maxSteps, 64);
45
+ assert.match(result.message ?? "", /MAX_STEPS is currently exported in your shell/);
46
+ const persisted = await readFile(path.join(home, ".env"), "utf8");
47
+ assert.match(persisted, /^MAX_STEPS=64$/m);
47
48
  }
48
49
  finally {
49
50
  if (previous === undefined) {
@@ -76,8 +77,8 @@ test("handleConfigSlashCommand reports config layers with /config get", async ()
76
77
  });
77
78
  assert.equal(getResult.handled, true);
78
79
  assert.match(getResult.message ?? "", /effective: openai-compatible/);
79
- assert.match(getResult.message ?? "", /config file: openai-compatible/);
80
- assert.match(getResult.message ?? "", /env override \(MODEL_PROVIDER\): openai-compatible/);
80
+ assert.match(getResult.message ?? "", /saved in ~\/\.minicode\/\.env: openai-compatible/);
81
+ assert.match(getResult.message ?? "", /exported env override \(MODEL_PROVIDER\): openai-compatible/);
81
82
  }
82
83
  finally {
83
84
  if (previous === undefined) {
@@ -102,5 +103,5 @@ test("handleConfigSlashCommand rejects non-editable keys and keeps secrets env-o
102
103
  });
103
104
  test("getGlobalConfigPath resolves to minicode home", () => {
104
105
  const home = "/tmp/example-home";
105
- assert.equal(getGlobalConfigPath(home), path.join(home, "agent.config.json"));
106
+ assert.equal(getGlobalConfigPath(home), path.join(home, ".env"));
106
107
  });
@@ -47,8 +47,7 @@ test("loadAgentConfig uses global config and env vars with correct precedence",
47
47
  tempDirs.push(base);
48
48
  const minicodeHome = path.join(base, "home");
49
49
  await mkdir(minicodeHome, { recursive: true });
50
- await writeFile(path.join(minicodeHome, "agent.config.json"), JSON.stringify({ model: "global-model", maxSteps: 33 }, null, 2) + "\n", "utf8");
51
- await writeFile(path.join(minicodeHome, ".env"), "MODEL=home-env-model\n", "utf8");
50
+ await writeFile(path.join(minicodeHome, ".env"), "MODEL=home-env-model\nMAX_STEPS=33\n", "utf8");
52
51
  const previousMaxSteps = process.env.MAX_STEPS;
53
52
  const previousModel = process.env.MODEL;
54
53
  try {
@@ -56,9 +55,7 @@ test("loadAgentConfig uses global config and env vars with correct precedence",
56
55
  process.env.MAX_STEPS = "120";
57
56
  delete process.env.MODEL;
58
57
  const config = await loadAgentConfig("/tmp", { minicodeHome });
59
- // MODEL from ~/.minicode/.env (no shell override)
60
58
  assert.equal(config.model, "home-env-model");
61
- // MAX_STEPS from shell env (overrides config file value of 33)
62
59
  assert.equal(config.maxSteps, 120);
63
60
  }
64
61
  finally {
@@ -76,3 +73,14 @@ test("loadAgentConfig uses global config and env vars with correct precedence",
76
73
  }
77
74
  }
78
75
  });
76
+ test("loadAgentConfig appends COMMAND_DENYLIST patterns from env", async () => {
77
+ const base = await mkdtemp(path.join(os.tmpdir(), "minicode-config-test-"));
78
+ tempDirs.push(base);
79
+ const minicodeHome = path.join(base, "home");
80
+ await mkdir(minicodeHome, { recursive: true });
81
+ await writeFile(path.join(minicodeHome, ".env"), 'MODEL=test-model\nCOMMAND_DENYLIST=["custom-danger","^wipe-db$"]\n', "utf8");
82
+ const config = await loadAgentConfig("/tmp", { minicodeHome });
83
+ const serialized = config.commandDenylist.map((pattern) => pattern.source);
84
+ assert.ok(serialized.includes("custom-danger"));
85
+ assert.ok(serialized.includes("^wipe-db$"));
86
+ });
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { afterEach, test } from "node:test";
@@ -47,7 +47,7 @@ test("buildStructuredConfigPayload reports effective values and env overrides",
47
47
  maxSteps: 120,
48
48
  model: "global-model",
49
49
  }, home);
50
- assert.equal(payload.configPath, path.join(home, "agent.config.json"));
50
+ assert.equal(payload.configPath, path.join(home, ".env"));
51
51
  const maxSteps = payload.entries.find((entry) => entry.key === "maxSteps");
52
52
  assert.equal(maxSteps?.effectiveValue, 120);
53
53
  assert.equal(maxSteps?.persistedValue, 77);
@@ -73,13 +73,14 @@ test("buildStructuredConfigPayload reports home dotenv env source", async () =>
73
73
  maxSteps: 88,
74
74
  }, home);
75
75
  const maxSteps = payload.entries.find((entry) => entry.key === "maxSteps");
76
- assert.equal(maxSteps?.envValue, "88");
77
- assert.equal(maxSteps?.envSource, "home-dotenv");
78
- assert.equal(maxSteps?.envSourcePath, path.join(home, ".env"));
79
- assert.equal(maxSteps?.overriddenByEnv, true);
76
+ assert.equal(maxSteps?.persistedValue, 88);
77
+ assert.equal(maxSteps?.envValue, null);
78
+ assert.equal(maxSteps?.envSource, null);
79
+ assert.equal(maxSteps?.envSourcePath, null);
80
+ assert.equal(maxSteps?.overriddenByEnv, false);
80
81
  });
81
82
  });
82
- test("applyPersistedConfigUpdates writes global config and removes files when everything is unset", async () => {
83
+ test("applyPersistedConfigUpdates writes ~/.minicode/.env and removes keys when unset", async () => {
83
84
  const home = await mkdtemp(path.join(os.tmpdir(), "minicode-editable-config-"));
84
85
  tempDirs.push(home);
85
86
  const result = await applyPersistedConfigUpdates({
@@ -94,10 +95,10 @@ test("applyPersistedConfigUpdates writes global config and removes files when ev
94
95
  { key: "keepRecentMessages", value: 18 },
95
96
  { key: "enableFileReadDedup", value: false },
96
97
  ]);
97
- const configPath = path.join(home, "agent.config.json");
98
- const persisted = JSON.parse(await readFile(configPath, "utf8"));
99
- assert.equal(persisted.keepRecentMessages, 18);
100
- assert.equal(persisted.enableFileReadDedup, false);
98
+ const envPath = path.join(home, ".env");
99
+ let persisted = await readFile(envPath, "utf8");
100
+ assert.match(persisted, /^KEEP_RECENT_MESSAGES=18$/m);
101
+ assert.match(persisted, /^ENABLE_FILE_READ_DEDUP=false$/m);
101
102
  await applyPersistedConfigUpdates({
102
103
  minicodeHome: home,
103
104
  updates: {
@@ -105,5 +106,7 @@ test("applyPersistedConfigUpdates writes global config and removes files when ev
105
106
  enableFileReadDedup: null,
106
107
  },
107
108
  });
108
- await assert.rejects(access(configPath));
109
+ persisted = await readFile(envPath, "utf8");
110
+ assert.doesNotMatch(persisted, /^KEEP_RECENT_MESSAGES=/m);
111
+ assert.doesNotMatch(persisted, /^ENABLE_FILE_READ_DEDUP=/m);
109
112
  });
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { test } from "node:test";
6
- import { createEditFileTool, createReadFileTool } from "@minicode/agent-sdk";
6
+ import { createEditFileTool, createReadFileTool, createRunCommandTool, createWriteFileTool } from "@minicode/agent-sdk";
7
7
  import { buildProjectIndex } from "../src/indexer/project-index.js";
8
8
  import { createTestAgentConfig } from "./test-utils.js";
9
9
  async function createTempWorkspace() {
@@ -58,6 +58,39 @@ test("edit_file triggers reindex when projectIndex provided", async () => {
58
58
  const after = index.getSymbol("add");
59
59
  assert.ok(after?.signature.includes("c?: number"), "index should reflect edit");
60
60
  });
61
+ test("write_file triggers reindex when projectIndex provided", async () => {
62
+ const workspaceRoot = await createTempWorkspace();
63
+ const index = await buildProjectIndex(workspaceRoot);
64
+ const writeTool = createWriteFileTool(createTestAgentConfig(workspaceRoot), { afterWrite: (relPath, content) => index.reindexFile(relPath, content) });
65
+ await writeTool.execute({
66
+ path: "src/util.ts",
67
+ content: `export function add(a: number, b: number): number {\n return a + b;\n}\n`,
68
+ });
69
+ const added = index.getSymbol("add");
70
+ assert.ok(added?.signature.includes("a: number, b: number"), "index should reflect newly written file");
71
+ });
72
+ test("run_command refreshes index after shell-created file changes", async () => {
73
+ const workspaceRoot = await createTempWorkspace();
74
+ const index = await buildProjectIndex(workspaceRoot);
75
+ const runTool = createRunCommandTool(createTestAgentConfig(workspaceRoot), { afterCommand: async () => index.refreshFromWorkspace() });
76
+ await runTool.execute({
77
+ command: "mkdir -p src && cat <<'EOF' > src/util.ts\nexport function add(a: number, b: number): number {\n return a + b;\n}\nEOF",
78
+ });
79
+ const added = index.getSymbol("add");
80
+ assert.ok(added?.signature.includes("a: number, b: number"), "index should reflect shell-created file");
81
+ });
82
+ test("run_command refresh removes deleted files from the index", async () => {
83
+ const workspaceRoot = await createTempWorkspace();
84
+ const { mkdir } = await import("node:fs/promises");
85
+ const filePath = path.join(workspaceRoot, "src", "util.ts");
86
+ await mkdir(path.dirname(filePath), { recursive: true });
87
+ await writeFile(filePath, `export function add(a: number, b: number): number {\n return a + b;\n}\n`, "utf8");
88
+ const index = await buildProjectIndex(workspaceRoot);
89
+ assert.ok(index.getSymbol("add"));
90
+ const runTool = createRunCommandTool(createTestAgentConfig(workspaceRoot), { afterCommand: async () => index.refreshFromWorkspace() });
91
+ await runTool.execute({ command: "rm src/util.ts" });
92
+ assert.equal(index.getSymbol("add"), undefined, "deleted file should be removed from the index");
93
+ });
61
94
  test("read_file supports negative offset and line limits", async () => {
62
95
  const workspaceRoot = await createTempWorkspace();
63
96
  const filePath = path.join(workspaceRoot, "lines.txt");
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import path from "node:path";
3
3
  import { test } from "node:test";
4
- import { createProjectIndex } from "../src/indexer/project-index.js";
4
+ import { mkdtemp, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { buildProjectIndex, createProjectIndex } from "../src/indexer/project-index.js";
5
7
  import { createFindPathTool } from "../src/tools/find-path.js";
6
8
  function makeSymbol(name, kind = "function") {
7
9
  return {
@@ -169,7 +171,6 @@ test("find_path tool reports no path when symbols are disconnected", async () =>
169
171
  assert.ok(result.includes("No path found"));
170
172
  });
171
173
  test("find_path tool works with real project index", async () => {
172
- const { buildProjectIndex } = await import("../src/indexer/project-index.js");
173
174
  const root = path.resolve(import.meta.dirname, "..");
174
175
  const projectIndex = await buildProjectIndex(root);
175
176
  const tool = createFindPathTool(projectIndex);
@@ -181,3 +182,43 @@ test("find_path tool works with real project index", async () => {
181
182
  assert.ok(result.includes("# Path from createModelClient to AgentConfig"));
182
183
  assert.ok(result.includes("symbols"));
183
184
  });
185
+ test("find_path returns disambiguation list for ambiguous bare target symbols", async () => {
186
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-find-path-collisions-"));
187
+ await writeFile(path.join(workspaceRoot, "sample.ts"), `export type Review = { id: string };
188
+
189
+ export class Review {
190
+ constructor(public id: string) {}
191
+ }
192
+
193
+ export function createReview(id: string) {
194
+ return new Review(id);
195
+ }
196
+ `, "utf8");
197
+ const projectIndex = await buildProjectIndex(workspaceRoot);
198
+ const tool = createFindPathTool(projectIndex);
199
+ const result = await tool.execute({ from: "createReview", to: "Review" });
200
+ assert.ok(result.includes('Symbol "Review" is ambiguous'));
201
+ assert.ok(result.includes("Review (type)"));
202
+ assert.ok(result.includes("Review (class)"));
203
+ assert.ok(result.includes("qualified: Review#type"));
204
+ assert.ok(result.includes("qualified: Review#class"));
205
+ });
206
+ test("find_path accepts qualified names for colliding symbols", async () => {
207
+ const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-find-path-qualified-"));
208
+ await writeFile(path.join(workspaceRoot, "sample.ts"), `export type Review = { id: string };
209
+
210
+ export class Review {
211
+ constructor(public id: string) {}
212
+ }
213
+
214
+ export function createReview(id: string) {
215
+ return new Review(id);
216
+ }
217
+ `, "utf8");
218
+ const projectIndex = await buildProjectIndex(workspaceRoot);
219
+ const tool = createFindPathTool(projectIndex);
220
+ const result = await tool.execute({ from: "createReview", to: "Review#class" });
221
+ assert.ok(result.includes("# Path from createReview to Review (class)"));
222
+ assert.ok(result.includes("[function] createReview"));
223
+ assert.ok(result.includes("[class] Review (class)"));
224
+ });