mini-coder 0.5.12 → 0.5.13

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/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Entry point for mini-coder.
3
3
  *
4
- * Discovers available LLM providers, loads context (AGENTS.md, skills,
5
- * plugins), opens the session database, selects a model, and starts
6
- * the TUI.
4
+ * Discovers available LLM providers, loads configured MCP tools,
5
+ * loads prompt context (AGENTS.md, skills, and theme), opens the session
6
+ * database, selects a model, and starts the TUI.
7
7
  *
8
8
  * @module
9
9
  */
@@ -14,7 +14,6 @@ import { basename, dirname, join } from "node:path";
14
14
  import { isDeepStrictEqual } from "node:util";
15
15
  import type {
16
16
  KnownProvider,
17
- Message,
18
17
  Model,
19
18
  OAuthCredentials,
20
19
  ThinkingLevel,
@@ -33,15 +32,8 @@ import {
33
32
  } from "./cli.ts";
34
33
  import { getErrorMessage } from "./errors.ts";
35
34
  import { type GitState, getGitState } from "./git.ts";
35
+ import { discoverMcpServers, type McpServerState } from "./mcp.ts";
36
36
  import { canonicalizePath } from "./paths.ts";
37
- import {
38
- type AgentContext,
39
- destroyPlugins,
40
- initPlugins,
41
- type LoadedPlugin,
42
- loadPluginConfig,
43
- type PluginEntry,
44
- } from "./plugins.ts";
45
37
  import {
46
38
  type AgentsMdFile,
47
39
  buildSystemPrompt,
@@ -52,7 +44,6 @@ import {
52
44
  appendMessage,
53
45
  createConversationSnapshot,
54
46
  createSession,
55
- filterModelMessages,
56
47
  type loadMessages,
57
48
  openDatabase,
58
49
  type Session,
@@ -61,12 +52,13 @@ import {
61
52
  } from "./session.ts";
62
53
  import {
63
54
  type CustomProvider,
64
- loadSettings,
55
+ loadStartupSettings,
56
+ mergeUserSettings,
65
57
  resolveStartupSettings,
66
58
  type UserSettings,
67
59
  } from "./settings.ts";
68
60
  import { discoverSkills, type Skill } from "./skills.ts";
69
- import { DEFAULT_THEME, mergeThemes, type Theme } from "./theme.ts";
61
+ import { DEFAULT_THEME, type Theme } from "./theme.ts";
70
62
  import {
71
63
  createTodoReadToolHandler,
72
64
  createTodoWriteToolHandler,
@@ -95,9 +87,6 @@ const DATA_DIR = join(homedir(), ".config", "mini-coder");
95
87
  /** SQLite database path. */
96
88
  const DB_PATH = join(DATA_DIR, "mini-coder.db");
97
89
 
98
- /** Plugin config file path. */
99
- const PLUGIN_CONFIG_PATH = join(DATA_DIR, "plugins.json");
100
-
101
90
  /** OAuth credentials file path. */
102
91
  const AUTH_PATH = join(DATA_DIR, "auth.json");
103
92
 
@@ -370,8 +359,8 @@ function selectModel(
370
359
  */
371
360
  function buildTools(
372
361
  model: Model<string>,
373
- plugins: LoadedPlugin[],
374
362
  messages: AppState["messages"],
363
+ mcpServers: readonly McpServerState[],
375
364
  ): { tools: Tool[]; toolHandlers: Map<string, ToolHandler> } {
376
365
  const tools: Tool[] = [
377
366
  shellTool,
@@ -390,26 +379,23 @@ function buildTools(
390
379
  [todoReadTool.name, createTodoReadToolHandler(messages)],
391
380
  ]);
392
381
 
382
+ for (const server of mcpServers) {
383
+ if (!server.enabled || !server.connected) {
384
+ continue;
385
+ }
386
+
387
+ tools.push(...server.tools);
388
+ for (const [name, handler] of server.toolHandlers) {
389
+ toolHandlers.set(name, handler);
390
+ }
391
+ }
392
+
393
393
  // Conditionally register readImage for vision-capable models
394
394
  if (model.input.includes("image")) {
395
395
  tools.push(readImageTool);
396
396
  toolHandlers.set(readImageTool.name, readImageToolHandler);
397
397
  }
398
398
 
399
- // Add plugin tools
400
- for (const plugin of plugins) {
401
- if (plugin.result.tools) {
402
- for (const tool of plugin.result.tools) {
403
- tools.push(tool);
404
- }
405
- }
406
- if (plugin.result.toolHandlers) {
407
- for (const [name, handler] of plugin.result.toolHandlers) {
408
- toolHandlers.set(name, handler);
409
- }
410
- }
411
- }
412
-
413
399
  return { tools, toolHandlers };
414
400
  }
415
401
 
@@ -429,21 +415,13 @@ function getSkillScanPaths(cwd: string, gitRoot: string | null): string[] {
429
415
  ];
430
416
  }
431
417
 
432
- /** Load AGENTS.md files, skills, plugins, git state, and the merged theme. */
433
- export async function loadPromptContext(
434
- messages: readonly Message[],
435
- opts?: {
436
- cwd?: string;
437
- pluginEntries?: PluginEntry[];
438
- pluginConfigPath?: string;
439
- },
440
- ): Promise<{
418
+ /** Load AGENTS.md files, skills, git state, and the active theme. */
419
+ export async function loadPromptContext(opts?: { cwd?: string }): Promise<{
441
420
  cwd: string;
442
421
  canonicalCwd: string;
443
422
  git: GitState | null;
444
423
  agentsMd: AgentsMdFile[];
445
424
  skills: Skill[];
446
- plugins: LoadedPlugin[];
447
425
  theme: Theme;
448
426
  }> {
449
427
  const cwd = opts?.cwd ?? process.cwd();
@@ -459,20 +437,6 @@ export async function loadPromptContext(
459
437
  );
460
438
  const agentsMd = discoverAgentsMd(cwd, scanRoot, join(home, ".agents"));
461
439
  const skills = discoverSkills(getSkillScanPaths(canonicalCwd, gitRoot));
462
- const pluginEntries =
463
- opts?.pluginEntries ??
464
- loadPluginConfig(opts?.pluginConfigPath ?? PLUGIN_CONFIG_PATH);
465
- const context: AgentContext = {
466
- cwd,
467
- messages,
468
- dataDir: DATA_DIR,
469
- };
470
- const plugins = await initPlugins(pluginEntries, context, (entry, err) => {
471
- console.error(`Plugin "${entry.name}" failed to init: ${err.message}`);
472
- });
473
- const themeOverrides = plugins
474
- .map((plugin) => plugin.result.theme)
475
- .filter((theme): theme is Partial<Theme> => theme != null);
476
440
 
477
441
  return {
478
442
  cwd,
@@ -480,8 +444,38 @@ export async function loadPromptContext(
480
444
  git,
481
445
  agentsMd,
482
446
  skills,
483
- plugins,
484
- theme: mergeThemes(DEFAULT_THEME, ...themeOverrides),
447
+ theme: DEFAULT_THEME,
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Load global and repo-local settings for the current launch.
453
+ *
454
+ * The repo-local overlay is read only and is loaded only when a git root is
455
+ * known. Invalid startup content in either file is treated as empty settings.
456
+ *
457
+ * @param opts - Optional settings path and git-root override for tests.
458
+ * @returns Global settings, repo-local overlay settings, and the merged result.
459
+ */
460
+ export function loadUserSettingsForLaunch(opts?: {
461
+ settingsPath?: string;
462
+ gitRoot?: string | null;
463
+ }): {
464
+ settings: UserSettings;
465
+ repoSettings: UserSettings;
466
+ effectiveSettings: UserSettings;
467
+ } {
468
+ const settingsPath = opts?.settingsPath ?? SETTINGS_PATH;
469
+ const gitRoot = opts?.gitRoot ?? null;
470
+ const settings = loadStartupSettings(settingsPath);
471
+ const repoSettings = gitRoot
472
+ ? loadStartupSettings(join(gitRoot, ".mini-coder", "settings.json"))
473
+ : {};
474
+
475
+ return {
476
+ settings,
477
+ repoSettings,
478
+ effectiveSettings: mergeUserSettings(settings, repoSettings),
485
479
  };
486
480
  }
487
481
 
@@ -490,25 +484,17 @@ export async function reloadPromptContext(
490
484
  state: AppState,
491
485
  runtime?: {
492
486
  loadPromptContext?: typeof loadPromptContext;
493
- destroyPlugins?: typeof destroyPlugins;
494
487
  },
495
488
  ): Promise<void> {
496
489
  const loadContext = runtime?.loadPromptContext ?? loadPromptContext;
497
- const destroyLoadedPlugins = runtime?.destroyPlugins ?? destroyPlugins;
498
- const previousPlugins = state.plugins;
499
- const context = await loadContext(filterModelMessages(state.messages));
490
+ const context = await loadContext();
500
491
 
501
492
  state.cwd = context.cwd;
502
493
  state.canonicalCwd = context.canonicalCwd;
503
494
  state.git = context.git;
504
495
  state.agentsMd = context.agentsMd;
505
496
  state.skills = context.skills;
506
- state.plugins = context.plugins;
507
497
  state.theme = context.theme;
508
-
509
- await destroyLoadedPlugins(previousPlugins, (entry, err) => {
510
- console.error(`Plugin "${entry.name}" failed to destroy: ${err.message}`);
511
- });
512
498
  }
513
499
 
514
500
  // ---------------------------------------------------------------------------
@@ -535,9 +521,7 @@ export interface AppState {
535
521
  agentsMd: AgentsMdFile[];
536
522
  /** Discovered skills. */
537
523
  skills: Skill[];
538
- /** Loaded plugins. */
539
- plugins: LoadedPlugin[];
540
- /** Active theme (default + plugin overrides). */
524
+ /** Active theme. */
541
525
  theme: Theme;
542
526
  /** Version label shown in the empty conversation banner. */
543
527
  versionLabel: string;
@@ -549,6 +533,8 @@ export interface AppState {
549
533
  oauthCredentials: Record<string, OAuthCredentials>;
550
534
  /** Loaded global user settings. */
551
535
  settings: UserSettings;
536
+ /** Loaded repo-local settings overlay for the current app run. */
537
+ repoSettings: UserSettings;
552
538
  /** Absolute path to the global settings file. */
553
539
  settingsPath: string;
554
540
  /** Working directory as entered by the user/shell (for display and tool execution). */
@@ -567,9 +553,11 @@ export interface AppState {
567
553
  showReasoning: boolean;
568
554
  /** Whether to show full (un-truncated) tool output. */
569
555
  verbose: boolean;
556
+ /** Configured MCP servers, including their current enabled/disabled state. */
557
+ mcpServers: McpServerState[];
570
558
  /** Models discovered from custom OpenAI-compatible providers. */
571
559
  customModels: Model<string>[];
572
- /** Warnings from startup (e.g. unreachable custom providers). */
560
+ /** Warnings from startup (e.g. unreachable custom providers or MCP servers). */
573
561
  startupWarnings: string[];
574
562
  }
575
563
 
@@ -587,13 +575,17 @@ export async function init(): Promise<AppState> {
587
575
  // Discover providers (env + OAuth)
588
576
  const { providers, oauthCredentials } = await discoverProviders();
589
577
 
590
- // Load user settings and resolve startup defaults
591
- const settings = loadSettings(SETTINGS_PATH);
578
+ const promptContext = await loadPromptContext({ cwd });
579
+ const { settings, repoSettings, effectiveSettings } =
580
+ loadUserSettingsForLaunch({
581
+ settingsPath: SETTINGS_PATH,
582
+ gitRoot: promptContext.git?.root ?? null,
583
+ });
592
584
 
593
- // Discover custom providers from settings
585
+ // Discover custom providers from effective settings
594
586
  const builtInProviderNames = new Set(providers.keys());
595
587
  const customResult = await discoverCustomProviders(
596
- settings.customProviders ?? [],
588
+ effectiveSettings.customProviders ?? [],
597
589
  builtInProviderNames,
598
590
  );
599
591
 
@@ -602,10 +594,12 @@ export async function init(): Promise<AppState> {
602
594
  providers.set(name, key);
603
595
  }
604
596
 
597
+ const mcpResult = await discoverMcpServers(effectiveSettings.mcp);
598
+
605
599
  const builtInModels = listAvailableModels(providers);
606
600
  const availableModels = [...builtInModels, ...customResult.models];
607
601
  const startup = resolveStartupSettings(
608
- settings,
602
+ effectiveSettings,
609
603
  availableModels.map((model) => `${model.provider}/${model.id}`),
610
604
  );
611
605
  const model = selectModel(availableModels, startup.modelId);
@@ -614,12 +608,6 @@ export async function init(): Promise<AppState> {
614
608
  const db = openDatabase(DB_PATH);
615
609
  const effort = startup.effort;
616
610
  const conversation = createConversationSnapshot();
617
- const promptContext = await loadPromptContext(
618
- filterModelMessages(conversation.messages),
619
- {
620
- cwd,
621
- },
622
- );
623
611
 
624
612
  return {
625
613
  db,
@@ -631,13 +619,13 @@ export async function init(): Promise<AppState> {
631
619
  contextTokens: conversation.contextTokens,
632
620
  agentsMd: promptContext.agentsMd,
633
621
  skills: promptContext.skills,
634
- plugins: promptContext.plugins,
635
622
  theme: promptContext.theme,
636
623
  versionLabel: resolveAppVersionLabel(),
637
624
  git: promptContext.git,
638
625
  providers,
639
626
  oauthCredentials,
640
627
  settings,
628
+ repoSettings,
641
629
  settingsPath: SETTINGS_PATH,
642
630
  cwd: promptContext.cwd,
643
631
  canonicalCwd: promptContext.canonicalCwd,
@@ -647,8 +635,9 @@ export async function init(): Promise<AppState> {
647
635
  queuedUserMessages: [],
648
636
  showReasoning: startup.showReasoning,
649
637
  verbose: startup.verbose,
638
+ mcpServers: mcpResult.servers,
650
639
  customModels: customResult.models,
651
- startupWarnings: customResult.warnings,
640
+ startupWarnings: [...customResult.warnings, ...mcpResult.warnings],
652
641
  };
653
642
  }
654
643
 
@@ -686,9 +675,6 @@ export function buildPrompt(state: AppState): string {
686
675
  git: state.git,
687
676
  agentsMd: state.agentsMd,
688
677
  skills: state.skills,
689
- pluginSuffixes: state.plugins
690
- .map((p) => p.result.systemPromptSuffix)
691
- .filter((s): s is string => s != null),
692
678
  });
693
679
  }
694
680
 
@@ -698,7 +684,7 @@ export function buildToolList(state: AppState): {
698
684
  toolHandlers: Map<string, ToolHandler>;
699
685
  } {
700
686
  if (!state.model) return { tools: [], toolHandlers: new Map() };
701
- return buildTools(state.model, state.plugins, state.messages);
687
+ return buildTools(state.model, state.messages, state.mcpServers);
702
688
  }
703
689
 
704
690
  /**
@@ -747,9 +733,7 @@ export function getAvailableModels(state: AppState): Model<string>[] {
747
733
 
748
734
  /** Clean up resources on shutdown. */
749
735
  export async function shutdown(state: AppState): Promise<void> {
750
- await destroyPlugins(state.plugins, (entry, err) => {
751
- console.error(`Plugin "${entry.name}" failed to destroy: ${err.message}`);
752
- });
736
+ await Promise.allSettled(state.mcpServers.map((server) => server.close()));
753
737
  state.db.close();
754
738
  }
755
739
 
@@ -776,7 +760,8 @@ type HeadlessCliStopReason = "stop" | "length" | "error" | "aborted";
776
760
  *
777
761
  * Non-TTY detection only decides whether headless mode should run at all.
778
762
  * Once headless mode is selected, `--json` is the only switch that chooses
779
- * NDJSON streaming versus final-text output.
763
+ * NDJSON streaming versus the default text mode (stdout final answer plus
764
+ * stderr activity snippets).
780
765
  *
781
766
  * @param state - Initialized application state for the run.
782
767
  * @param cli - Parsed CLI options.
package/src/input.ts CHANGED
@@ -23,6 +23,7 @@ export const COMMANDS = [
23
23
  "undo",
24
24
  "reasoning",
25
25
  "verbose",
26
+ "mcp",
26
27
  "todo",
27
28
  "login",
28
29
  "logout",
@@ -31,8 +32,11 @@ export const COMMANDS = [
31
32
  "effort",
32
33
  ] as const;
33
34
 
35
+ /** Slash helper that opens the interactive skill picker when submitted alone. */
36
+ export const SKILL_COMMAND = "skill" as const;
37
+
34
38
  /** A recognized slash command name. */
35
- type Command = (typeof COMMANDS)[number];
39
+ type Command = (typeof COMMANDS)[number] | typeof SKILL_COMMAND;
36
40
 
37
41
  const COMMAND_SET: ReadonlySet<string> = new Set(COMMANDS);
38
42
 
@@ -73,6 +77,14 @@ function isCommand(value: string): value is Command {
73
77
  }
74
78
 
75
79
  function parseSlashInput(trimmed: string): ParsedInput | null {
80
+ if (trimmed === `/${SKILL_COMMAND}`) {
81
+ return {
82
+ type: "command",
83
+ command: SKILL_COMMAND,
84
+ args: "",
85
+ };
86
+ }
87
+
76
88
  const skillMatch = trimmed.match(/^\/skill:(\S+)(?:\s+(.*))?$/s);
77
89
  if (skillMatch?.[1]) {
78
90
  return {