mu-coding 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +49 -5
  2. package/bin/mu.js +1 -1
  3. package/package.json +17 -4
  4. package/prompts/SYSTEM.md +16 -0
  5. package/src/app/shutdown.ts +94 -0
  6. package/src/app/startApp.ts +43 -0
  7. package/src/cli/args.ts +131 -0
  8. package/src/{install.ts → cli/install.ts} +19 -15
  9. package/src/config/index.test.ts +77 -0
  10. package/src/config/index.ts +199 -0
  11. package/src/main.ts +4 -0
  12. package/src/plugin.ts +96 -0
  13. package/src/runtime/codingTools/bash.ts +114 -0
  14. package/src/runtime/codingTools/edit-file.ts +60 -0
  15. package/src/runtime/codingTools/index.ts +39 -0
  16. package/src/runtime/codingTools/read-file.ts +83 -0
  17. package/src/runtime/codingTools/utils.ts +21 -0
  18. package/src/runtime/codingTools/write-file.ts +42 -0
  19. package/src/runtime/createRegistry.test.ts +146 -0
  20. package/src/runtime/createRegistry.ts +163 -0
  21. package/src/runtime/messageBus.test.ts +62 -0
  22. package/src/runtime/messageBus.ts +78 -0
  23. package/src/runtime/pluginLoader.ts +122 -0
  24. package/src/sessions/index.test.ts +66 -0
  25. package/src/sessions/index.ts +183 -0
  26. package/src/sessions/peek.test.ts +88 -0
  27. package/src/sessions/project.ts +51 -0
  28. package/src/tui/channel/tuiChannel.test.ts +107 -0
  29. package/src/tui/channel/tuiChannel.ts +49 -0
  30. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  31. package/src/tui/chat/MessageRendererContext.ts +44 -0
  32. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  33. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  34. package/src/tui/chat/useAttachment.ts +74 -0
  35. package/src/tui/chat/useChat.ts +106 -0
  36. package/src/tui/chat/useChatPanel.ts +98 -0
  37. package/src/tui/chat/useChatSession.ts +284 -0
  38. package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
  39. package/src/tui/chat/usePluginStatus.ts +44 -0
  40. package/src/tui/chat/useSessionPersistence.ts +68 -0
  41. package/src/tui/chat/useStatusSegments.ts +62 -0
  42. package/src/tui/components/chat/ChatPanel.tsx +20 -40
  43. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  44. package/src/tui/components/chat/Pickers.tsx +2 -2
  45. package/src/tui/components/messageView.tsx +72 -0
  46. package/src/tui/components/messages/EditOutput.tsx +47 -30
  47. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  48. package/src/tui/components/messages/ToolHeader.tsx +28 -0
  49. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  50. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  51. package/src/tui/components/messages/messageItem.tsx +23 -16
  52. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  53. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  54. package/src/tui/components/messages/toolCallBlock.tsx +61 -38
  55. package/src/tui/components/messages/userMessage.tsx +21 -6
  56. package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
  57. package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
  58. package/src/tui/components/primitives/pickerModal.tsx +47 -0
  59. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  60. package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
  61. package/src/tui/components/statusBar.tsx +32 -0
  62. package/src/tui/components/ui/dialogLayer.tsx +32 -13
  63. package/src/tui/context/ThemeContext.tsx +18 -0
  64. package/src/tui/hooks/useScroll.ts +11 -3
  65. package/src/tui/input/InputBox.tsx +6 -0
  66. package/src/tui/input/InputBoxView.tsx +237 -0
  67. package/src/tui/input/commands.test.ts +51 -0
  68. package/src/tui/input/commands.ts +44 -0
  69. package/src/tui/input/cursor.test.ts +136 -0
  70. package/src/tui/input/cursor.ts +214 -0
  71. package/src/tui/input/dumpContext.ts +107 -0
  72. package/src/tui/input/sanitize.ts +33 -0
  73. package/src/tui/input/useCommandExecutor.ts +32 -0
  74. package/src/tui/input/useInputBox.ts +207 -0
  75. package/src/tui/input/useInputHandler.ts +453 -0
  76. package/src/tui/input/useMentionPicker.ts +121 -0
  77. package/src/tui/input/usePluginShortcuts.ts +29 -0
  78. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  79. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  80. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  81. package/src/tui/renderApp.tsx +43 -0
  82. package/src/tui/theme/index.ts +1 -0
  83. package/src/tui/theme/merge.test.ts +49 -0
  84. package/src/tui/theme/merge.ts +43 -0
  85. package/src/tui/theme/presets.ts +79 -0
  86. package/src/tui/theme/types.ts +116 -0
  87. package/src/utils/clipboard.ts +97 -0
  88. package/src/utils/diff.test.ts +56 -0
  89. package/src/cli.ts +0 -96
  90. package/src/clipboard.ts +0 -62
  91. package/src/config.ts +0 -116
  92. package/src/main.tsx +0 -147
  93. package/src/project.ts +0 -32
  94. package/src/session.ts +0 -95
  95. package/src/tui/commands.ts +0 -33
  96. package/src/tui/components/chatLayout.tsx +0 -192
  97. package/src/tui/components/inputBox.tsx +0 -153
  98. package/src/tui/hooks/useInputHandler.ts +0 -268
  99. package/src/tui/useChat.ts +0 -52
  100. package/src/tui/useChatSession.ts +0 -155
  101. package/src/tui/useChatUI.ts +0 -51
  102. package/tsconfig.json +0 -10
  103. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  104. /package/src/{diff.ts → utils/diff.ts} +0 -0
package/README.md CHANGED
@@ -12,8 +12,6 @@ npm install -g mu-coding
12
12
 
13
13
  ```bash
14
14
  mu # Start interactive chat
15
- mu -p "prompt" # Single-shot prompt, then exit
16
- mu -m model -p "p" # Single-shot with specific model
17
15
  mu -m model # Interactive with specific model
18
16
  mu -c # Continue most recent session
19
17
  mu --session <path> # Resume a specific session file
@@ -42,16 +40,61 @@ Config files follow XDG conventions:
42
40
  }
43
41
  ```
44
42
 
43
+ ### Theming
44
+
45
+ The `theme` field selects the UI palette. Either name a built-in preset:
46
+
47
+ ```json
48
+ { "theme": "solarized-dark" }
49
+ ```
50
+
51
+ Built-in presets: `dark` (default), `light`, `solarized-dark`, `monochrome`.
52
+
53
+ Or pass an object to override individual leaves on top of a preset:
54
+
55
+ ```json
56
+ {
57
+ "theme": {
58
+ "preset": "dark",
59
+ "input": { "background": "#1e1e2e", "cursor": "#f5c2e7" },
60
+ "user": { "border": "magenta" },
61
+ "common": { "accent": "#89dceb" }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Color values accept Ink's named colors (`red`, `green`, `cyan`, `yellow`,
67
+ `magenta`, `blue`, `white`, `black`, `gray`) or hex strings (`#1a1a1a`).
68
+
69
+ Sections available: `input`, `user`, `assistant`, `tool`, `reasoning`,
70
+ `modal`, `toast`, `dropdown`, `dialog`, `diff`, `status`, `common`. See
71
+ `src/tui/theme/types.ts` for the full leaf list.
72
+
45
73
  ## Keyboard Shortcuts
46
74
 
75
+ ### Input editing
76
+
77
+ | Key | Action |
78
+ |-----|--------|
79
+ | `←` / `→` | Move cursor one character |
80
+ | `Ctrl+←` / `Ctrl+→` (or `Alt+←/→`) | Move cursor by word |
81
+ | `Home` / `End` (or `Ctrl+A` / `Ctrl+E`) | Jump to start / end of line |
82
+ | `↑` / `↓` | Move cursor between lines (multi-line buffer); navigate history at edges |
83
+ | `Backspace` | Delete char before cursor |
84
+ | `Delete` | Delete char under cursor |
85
+ | `Ctrl+W` | Delete previous word |
86
+ | `Ctrl+U` | Delete from start of line to cursor |
87
+ | `Ctrl+K` | Delete from cursor to end of line |
88
+
89
+ ### Submission & app
90
+
47
91
  | Key | Action |
48
92
  |-----|--------|
49
93
  | `Enter` | Send message |
50
- | `Shift+Enter` | New line |
94
+ | `Shift+Enter` (or `Ctrl+J`) | New line |
51
95
  | `Ctrl+S` | Send message |
52
96
  | `Ctrl+C` | Abort / Quit (press twice) |
53
97
  | `Esc` | Stop generation (press twice) |
54
- | `↑` / `↓` | Navigate input history |
55
98
  | `Ctrl+N` | New conversation |
56
99
  | `Ctrl+M` | Cycle models |
57
100
  | `Ctrl+O` | Model picker |
@@ -71,7 +114,8 @@ Config files follow XDG conventions:
71
114
 
72
115
  - Streams responses with live token/s display
73
116
  - Multi-turn tool calling (bash, read, write, edit files)
74
- - Code indexing via `mu-repomap` plugin (auto-loaded)
117
+ - Optional code indexing via the `mu-repomap` plugin (enable via `config.plugins`)
118
+ - Optional default agents (build/plan/explore/review) via `mu-coding-agents` (enable via `config.plugins`)
75
119
  - Image attachment support
76
120
  - Session persistence and resume
77
121
  - Mouse wheel scrolling
package/bin/mu.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env bun
2
- import '../src/main.tsx';
2
+ import '../src/main.ts';
package/package.json CHANGED
@@ -1,19 +1,32 @@
1
1
  {
2
2
  "name": "mu-coding",
3
- "version": "0.4.0",
3
+ "version": "0.8.0",
4
4
  "description": "Minimal terminal AI assistant for local models",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mu": "./bin/mu.js"
8
8
  },
9
+ "exports": {
10
+ ".": "./src/plugin.ts",
11
+ "./config": "./src/config/index.ts",
12
+ "./tools": "./src/runtime/codingTools/index.ts",
13
+ "./loader": "./src/runtime/pluginLoader.ts"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "prompts",
18
+ "src"
19
+ ],
9
20
  "scripts": {
10
21
  "dev": "bun --watch bin/mu.js",
11
- "start": "bun bin/mu.js"
22
+ "start": "bun bin/mu.js",
23
+ "test": "bun test"
12
24
  },
13
25
  "dependencies": {
14
26
  "ink": "^7.0.1",
15
- "mu-agents": "0.4.0",
16
- "mu-provider": "0.4.0",
27
+ "mu-agents": "0.8.0",
28
+ "mu-core": "0.8.0",
29
+ "mu-openai-provider": "0.8.0",
17
30
  "react": "^19.2.5"
18
31
  }
19
32
  }
@@ -0,0 +1,16 @@
1
+ You are mu, a terminal coding agent. Be concise, direct, accurate.
2
+
3
+ ## Working style
4
+ - Investigate before editing; don't guess at APIs.
5
+ - Issue independent tool calls in parallel.
6
+ - Ask only when genuinely ambiguous; otherwise proceed.
7
+ - After non-trivial edits, run the project's check command if known (e.g. `bun run check`).
8
+
9
+ ## Output
10
+ - Plain terminal text. Backticks for `paths`, `commands`, `identifiers`.
11
+ - Reference code as `path/to/file.ts:LINE`.
12
+ - No filler. Lead with the result or next action.
13
+
14
+ ## Safety
15
+ - Never run destructive commands (`rm -rf`, force-push, history rewrites) without explicit request.
16
+ - Never commit, amend, or push unless asked.
@@ -0,0 +1,94 @@
1
+ import type { PluginRegistry } from 'mu-core';
2
+
3
+ /**
4
+ * Escape sequences to disable every SGR mouse-tracking mode the TUI may have
5
+ * enabled (or inherited from a stale prior session). Disabling already-off
6
+ * modes is a no-op, so we send all three defensively to avoid leaking mouse
7
+ * tracking into the parent shell after abort.
8
+ * - 1000 = X10/normal (press+release) ← what `useScroll` enables
9
+ * - 1002 = button-event tracking (press+drag) ← legacy, prior versions
10
+ * - 1003 = any-event tracking (all motion) ← belt-and-suspenders
11
+ * - 1006 = SGR-encoded coordinates extension
12
+ */
13
+ const DISABLE_MOUSE_MODE = '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l';
14
+
15
+ /** Restore the kitty keyboard protocol stack — symmetric with renderApp's enable. */
16
+ const POP_KITTY_KEYBOARD = '\x1b[<u';
17
+
18
+ export type ShutdownFn = (code?: number) => Promise<void>;
19
+
20
+ let registered = false;
21
+
22
+ /**
23
+ * Install graceful-shutdown handlers and return a `shutdown` function the TUI
24
+ * can invoke directly when the user requests a quit.
25
+ *
26
+ * Coverage:
27
+ * - terminal close / external kill (SIGHUP, SIGTERM)
28
+ * - normal Node shutdown via `beforeExit`
29
+ * - uncaught exceptions / unhandled rejections (best-effort terminal restore)
30
+ * - explicit quit from `useAbort` (calls the returned `shutdown` directly)
31
+ *
32
+ * SIGINT is intentionally NOT trapped: Ink owns Ctrl+C through the `useInput`
33
+ * hook (`exitOnCtrlC: false` in renderApp) and `useAbort` implements the
34
+ * double-press quit UX, calling the returned function on confirmation.
35
+ *
36
+ * The function is idempotent — concurrent invocations resolve to the same
37
+ * outcome and the handlers fire only once.
38
+ *
39
+ * `getRegistry` is a thunk so the shutdown handle can be created BEFORE the
40
+ * registry (the registry consumes `shutdown` in its plugin context, creating
41
+ * a cycle if both were eager).
42
+ */
43
+ export function registerShutdown(getRegistry: () => PluginRegistry | null): ShutdownFn {
44
+ let shuttingDown: Promise<void> | null = null;
45
+
46
+ const shutdown: ShutdownFn = (code = 0) => {
47
+ if (shuttingDown) {
48
+ return shuttingDown;
49
+ }
50
+ shuttingDown = (async () => {
51
+ try {
52
+ const registry = getRegistry();
53
+ if (registry) {
54
+ await registry.shutdown();
55
+ }
56
+ } catch (err) {
57
+ console.error('Shutdown error:', err instanceof Error ? err.message : err);
58
+ } finally {
59
+ restoreTerminal();
60
+ // `process.exit` from inside an `async` function still terminates
61
+ // synchronously after the current microtask queue drains.
62
+ process.exit(code);
63
+ }
64
+ })();
65
+ return shuttingDown;
66
+ };
67
+
68
+ if (!registered) {
69
+ registered = true;
70
+ process.once('SIGTERM', () => void shutdown(143));
71
+ process.once('SIGHUP', () => void shutdown(129));
72
+ process.once('beforeExit', (code) => void shutdown(code));
73
+ process.once('uncaughtException', (err) => {
74
+ restoreTerminal();
75
+ console.error(err);
76
+ process.exit(1);
77
+ });
78
+ process.once('unhandledRejection', (err) => {
79
+ restoreTerminal();
80
+ console.error(err);
81
+ process.exit(1);
82
+ });
83
+ }
84
+
85
+ return shutdown;
86
+ }
87
+
88
+ export function restoreTerminal(): void {
89
+ try {
90
+ process.stdout.write(`${DISABLE_MOUSE_MODE}${POP_KITTY_KEYBOARD}`);
91
+ } catch {
92
+ // stdout may already be closed during teardown — nothing to do.
93
+ }
94
+ }
@@ -0,0 +1,43 @@
1
+ import type { PluginRegistry } from 'mu-core';
2
+ import { parseArgs, resolveInitialMessages } from '../cli/args';
3
+ import { handleSubcommand } from '../cli/subcommands';
4
+ import { loadConfig } from '../config/index';
5
+ import { createRegistry } from '../runtime/createRegistry';
6
+ import { InkUIService } from '../tui/plugins/InkUIService';
7
+ import { registerShutdown } from './shutdown';
8
+
9
+ async function runApp(): Promise<void> {
10
+ if (await handleSubcommand()) return;
11
+
12
+ const cliArgs = parseArgs();
13
+ const config = loadConfig(cliArgs.model);
14
+ const uiService = new InkUIService();
15
+
16
+ // Create the shutdown handle BEFORE the registry so we can pass it into the
17
+ // plugin context. The registry is bound through a thunk, filled in once
18
+ // construction completes.
19
+ let registryRef: PluginRegistry | null = null;
20
+ const shutdown = registerShutdown(() => registryRef);
21
+
22
+ const initialMessages = resolveInitialMessages(cliArgs);
23
+ const { registry, channels } = await createRegistry({
24
+ cwd: process.cwd(),
25
+ config,
26
+ uiService,
27
+ initialMessages,
28
+ shutdown,
29
+ });
30
+ registryRef = registry;
31
+
32
+ // The TUI is registered as a `Channel` by `createCodingPlugin`. Starting
33
+ // it mounts Ink with the same options that were captured at activation
34
+ // time (config, initialMessages, registry, messageBus, uiService, shutdown).
35
+ await channels.startAll();
36
+ }
37
+
38
+ export function startApp(): void {
39
+ runApp().catch((err) => {
40
+ console.error(err);
41
+ process.exit(1);
42
+ });
43
+ }
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parseArgs as nodeParseArgs } from 'node:util';
5
+ import type { ChatMessage } from 'mu-core';
6
+ import { getLatestSession, loadSession } from '../sessions/index';
7
+
8
+ interface CliArgs {
9
+ model?: string;
10
+ continueSession?: boolean;
11
+ sessionPath?: string;
12
+ }
13
+
14
+ function printHelp(): never {
15
+ console.log(`mu — minimal terminal AI assistant
16
+
17
+ Usage:
18
+ mu Start interactive chat
19
+ mu -m, --model <model> Interactive with specific model
20
+ mu -c, --continue Continue most recent session
21
+ mu --session <path> Resume a specific session file
22
+ mu install npm:<package> Install a plugin from npm
23
+ mu uninstall npm:<package> Remove an installed plugin
24
+ mu -v, --version Print version and exit
25
+ mu -h, --help Show this help
26
+
27
+ Config (XDG):
28
+ ~/.config/mu/config.json — configuration (baseUrl, model, streamTimeoutMs)
29
+ ~/.config/mu/SYSTEM.md — system prompt
30
+ ~/.local/share/mu/sessions/ — saved conversation sessions (JSONL)
31
+ ~/.cache/mu/repomap/ — code index cache
32
+
33
+ Keyboard shortcuts (interactive):
34
+ Ctrl+C Abort / Quit (press twice)
35
+ Esc Stop generation (press twice while streaming)
36
+ Enter Send message
37
+ Shift+Enter New line
38
+ Ctrl+S Send message
39
+ ← / → Move cursor (Ctrl/Alt+arrow: by word)
40
+ Home/End Start/end of line (or Ctrl+A / Ctrl+E)
41
+ ↑ / ↓ Move between lines; navigate history at edges
42
+ Backspace/Del Delete around cursor (Ctrl+W word, Ctrl+U/K line)
43
+ Ctrl+N New conversation
44
+ Ctrl+M Cycle models
45
+ Ctrl+O Model picker
46
+ Ctrl+V Paste image from clipboard`);
47
+ process.exit(0);
48
+ }
49
+
50
+ function printVersion(): never {
51
+ // Walk up from this file to find mu-coding's package.json. Works whether
52
+ // the file is loaded from `src/cli/args.ts` (bun --watch) or `dist/cli/args.js`.
53
+ const here = dirname(fileURLToPath(import.meta.url));
54
+ const candidates = [join(here, '..', '..', 'package.json'), join(here, '..', 'package.json')];
55
+ for (const path of candidates) {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(path, 'utf-8'));
58
+ if (pkg?.name === 'mu-coding') {
59
+ console.log(`mu ${pkg.version}`);
60
+ process.exit(0);
61
+ }
62
+ } catch {
63
+ // Try next candidate.
64
+ }
65
+ }
66
+ console.log('mu (version unknown)');
67
+ process.exit(0);
68
+ }
69
+
70
+ export function parseArgs(): CliArgs {
71
+ let parsed: ReturnType<typeof nodeParseArgs>;
72
+ try {
73
+ parsed = nodeParseArgs({
74
+ options: {
75
+ model: { type: 'string', short: 'm' },
76
+ continue: { type: 'boolean', short: 'c' },
77
+ session: { type: 'string' },
78
+ version: { type: 'boolean', short: 'v' },
79
+ help: { type: 'boolean', short: 'h' },
80
+ },
81
+ // Subcommands like `install`/`uninstall` are routed before parseArgs(),
82
+ // so we shouldn't see them here. Allow positionals just in case the
83
+ // user passes stray args (we ignore them rather than erroring).
84
+ allowPositionals: true,
85
+ strict: true,
86
+ });
87
+ } catch (err) {
88
+ console.error(err instanceof Error ? err.message : String(err));
89
+ console.error('Run `mu --help` for usage.');
90
+ process.exit(1);
91
+ }
92
+
93
+ if (parsed.values.help) {
94
+ printHelp();
95
+ }
96
+ if (parsed.values.version) {
97
+ printVersion();
98
+ }
99
+
100
+ return {
101
+ model: typeof parsed.values.model === 'string' ? parsed.values.model : undefined,
102
+ continueSession: parsed.values.continue === true,
103
+ sessionPath: typeof parsed.values.session === 'string' ? parsed.values.session : undefined,
104
+ };
105
+ }
106
+
107
+ export function resolveInitialMessages(cliArgs: CliArgs): ChatMessage[] | undefined {
108
+ if (cliArgs.sessionPath) {
109
+ const msgs = loadSession(cliArgs.sessionPath);
110
+ if (msgs.length === 0) {
111
+ console.error(`Error: session file is empty or not found: ${cliArgs.sessionPath}`);
112
+ process.exit(1);
113
+ }
114
+ return msgs;
115
+ }
116
+ if (cliArgs.continueSession) {
117
+ const latest = getLatestSession();
118
+ if (!latest) {
119
+ console.error('Error: no sessions found');
120
+ process.exit(1);
121
+ }
122
+ const msgs = loadSession(latest);
123
+ if (msgs.length === 0) {
124
+ console.error('Error: latest session is empty');
125
+ process.exit(1);
126
+ }
127
+ console.log(`Resuming session: ${latest}`);
128
+ return msgs;
129
+ }
130
+ return undefined;
131
+ }
@@ -1,7 +1,7 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { getDataDir, loadConfig, saveConfig } from './config';
4
+ import { canonicalNpmSpecifier, getDataDir, loadConfig, parseBareNpmSpec, saveConfig } from '../config/index';
5
5
 
6
6
  const INIT_PACKAGE_JSON = JSON.stringify({ private: true, dependencies: {} }, null, 2);
7
7
 
@@ -37,22 +37,24 @@ export async function runInstall(args: string[]): Promise<void> {
37
37
 
38
38
  for (const specifier of args) {
39
39
  const bare = stripNpmPrefix(specifier);
40
+ const canonical = canonicalNpmSpecifier(bare);
40
41
 
41
42
  console.log(`Installing ${bare}...`);
42
43
  try {
43
- execSync(`bun add ${bare}`, { cwd: dataDir, stdio: 'inherit' });
44
- } catch {
45
- console.error(`Failed to install ${bare}`);
44
+ execFileSync('bun', ['add', bare], { cwd: dataDir, stdio: 'inherit' });
45
+ } catch (err) {
46
+ console.error(`Failed to install ${bare}: ${err instanceof Error ? err.message : err}`);
46
47
  process.exit(1);
47
48
  }
48
49
 
49
- // Add to plugins if not already present
50
- const existing = plugins.some((p) => (typeof p === 'string' ? p : p.name) === specifier);
50
+ // Add to plugins if not already present (compare by canonical form so
51
+ // `npm:foo` and `npm:foo@1.2.3` deduplicate correctly).
52
+ const existing = plugins.some((p) => (typeof p === 'string' ? p : p.name) === canonical);
51
53
  if (!existing) {
52
- plugins.push(specifier);
54
+ plugins.push(canonical);
53
55
  }
54
56
 
55
- console.log(`✓ ${specifier}`);
57
+ console.log(`✓ ${canonical}`);
56
58
  }
57
59
 
58
60
  saveConfig({ plugins });
@@ -70,18 +72,20 @@ export async function runUninstall(args: string[]): Promise<void> {
70
72
 
71
73
  for (const specifier of args) {
72
74
  const bare = stripNpmPrefix(specifier);
75
+ const canonical = canonicalNpmSpecifier(bare);
76
+ const { name } = parseBareNpmSpec(bare);
73
77
 
74
- console.log(`Removing ${bare}...`);
78
+ console.log(`Removing ${name}...`);
75
79
  try {
76
- execSync(`bun remove ${bare}`, { cwd: dataDir, stdio: 'inherit' });
77
- } catch {
78
- console.error(`Failed to remove ${bare}`);
80
+ execFileSync('bun', ['remove', name], { cwd: dataDir, stdio: 'inherit' });
81
+ } catch (err) {
82
+ console.error(`Failed to remove ${name}: ${err instanceof Error ? err.message : err}`);
79
83
  process.exit(1);
80
84
  }
81
85
 
82
- plugins = plugins.filter((p) => (typeof p === 'string' ? p : p.name) !== specifier);
86
+ plugins = plugins.filter((p) => (typeof p === 'string' ? p : p.name) !== canonical);
83
87
 
84
- console.log(`✓ Removed ${specifier}`);
88
+ console.log(`✓ Removed ${canonical}`);
85
89
  }
86
90
 
87
91
  saveConfig({ plugins });
@@ -0,0 +1,77 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ // Important: stub XDG_CONFIG_HOME *before* importing './index' so the module
7
+ // captures the test paths in its top-level constants. Bun resolves dynamic
8
+ // imports per-call, so we use `await import` inside each test.
9
+ let tmpRoot: string;
10
+ let configPath: string;
11
+ let originalConfigHome: string | undefined;
12
+
13
+ beforeAll(() => {
14
+ tmpRoot = mkdtempSync(join(tmpdir(), 'mu-config-'));
15
+ originalConfigHome = process.env.XDG_CONFIG_HOME;
16
+ process.env.XDG_CONFIG_HOME = tmpRoot;
17
+ mkdirSync(join(tmpRoot, 'mu'), { recursive: true });
18
+ configPath = join(tmpRoot, 'mu', 'config.json');
19
+ });
20
+
21
+ afterAll(() => {
22
+ if (originalConfigHome === undefined) {
23
+ delete process.env.XDG_CONFIG_HOME;
24
+ } else {
25
+ process.env.XDG_CONFIG_HOME = originalConfigHome;
26
+ }
27
+ });
28
+
29
+ describe('saveConfig', () => {
30
+ it('preserves unknown keys in config.json', async () => {
31
+ const { saveConfig } = await import('./index');
32
+
33
+ // Seed with a custom key the loader doesn't know about.
34
+ writeFileSync(configPath, JSON.stringify({ baseUrl: 'http://x', customKey: 42 }, null, 2));
35
+
36
+ saveConfig({ model: 'qwen' });
37
+
38
+ const persisted = JSON.parse(readFileSync(configPath, 'utf-8'));
39
+ expect(persisted.customKey).toBe(42);
40
+ expect(persisted.model).toBe('qwen');
41
+ expect(persisted.baseUrl).toBe('http://x');
42
+ });
43
+
44
+ it('removes a key when explicitly set to undefined', async () => {
45
+ const { saveConfig } = await import('./index');
46
+ writeFileSync(configPath, JSON.stringify({ model: 'before' }));
47
+ saveConfig({ model: undefined });
48
+ const persisted = JSON.parse(readFileSync(configPath, 'utf-8'));
49
+ expect('model' in persisted).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('loadConfig theme', () => {
54
+ it('reads the theme field from config.json', async () => {
55
+ const { loadConfig } = await import('./index');
56
+ writeFileSync(
57
+ configPath,
58
+ JSON.stringify({ baseUrl: 'http://x', theme: { preset: 'light', input: { cursor: '#abcdef' } } }),
59
+ );
60
+ const cfg = loadConfig();
61
+ expect(cfg.theme).toEqual({ preset: 'light', input: { cursor: '#abcdef' } });
62
+ });
63
+
64
+ it('accepts a preset name string', async () => {
65
+ const { loadConfig } = await import('./index');
66
+ writeFileSync(configPath, JSON.stringify({ baseUrl: 'http://x', theme: 'solarized-dark' }));
67
+ const cfg = loadConfig();
68
+ expect(cfg.theme).toBe('solarized-dark');
69
+ });
70
+
71
+ it('omits theme when not present', async () => {
72
+ const { loadConfig } = await import('./index');
73
+ writeFileSync(configPath, JSON.stringify({ baseUrl: 'http://x' }));
74
+ const cfg = loadConfig();
75
+ expect(cfg.theme).toBeUndefined();
76
+ });
77
+ });