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.
- package/README.md +49 -5
- package/bin/mu.js +1 -1
- package/package.json +17 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +43 -0
- package/src/cli/args.ts +131 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +77 -0
- package/src/config/index.ts +199 -0
- package/src/main.ts +4 -0
- package/src/plugin.ts +96 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +146 -0
- package/src/runtime/createRegistry.ts +163 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +122 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +183 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +33 -0
- package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
- package/src/tui/chat/useAttachment.ts +74 -0
- package/src/tui/chat/useChat.ts +106 -0
- package/src/tui/chat/useChatPanel.ts +98 -0
- package/src/tui/chat/useChatSession.ts +284 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +68 -0
- package/src/tui/chat/useStatusSegments.ts +62 -0
- package/src/tui/components/chat/ChatPanel.tsx +20 -40
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +72 -0
- package/src/tui/components/messages/EditOutput.tsx +47 -30
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +28 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +23 -16
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +61 -38
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
- package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +47 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +32 -0
- package/src/tui/components/ui/dialogLayer.tsx +32 -13
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/input/InputBoxView.tsx +237 -0
- package/src/tui/input/commands.test.ts +51 -0
- package/src/tui/input/commands.ts +44 -0
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +207 -0
- package/src/tui/input/useInputHandler.ts +453 -0
- package/src/tui/input/useMentionPicker.ts +121 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +43 -0
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +79 -0
- package/src/tui/theme/types.ts +116 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -96
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -147
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- package/src/tui/components/inputBox.tsx +0 -153
- package/src/tui/hooks/useInputHandler.ts +0 -268
- package/src/tui/useChat.ts +0 -52
- package/src/tui/useChatSession.ts +0 -155
- package/src/tui/useChatUI.ts +0 -51
- package/tsconfig.json +0 -10
- /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
- /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
|
-
-
|
|
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.
|
|
2
|
+
import '../src/main.ts';
|
package/package.json
CHANGED
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mu-coding",
|
|
3
|
-
"version": "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.
|
|
16
|
-
"mu-
|
|
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
|
+
}
|
package/src/cli/args.ts
ADDED
|
@@ -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 {
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
54
|
+
plugins.push(canonical);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
console.log(`✓ ${
|
|
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 ${
|
|
78
|
+
console.log(`Removing ${name}...`);
|
|
75
79
|
try {
|
|
76
|
-
|
|
77
|
-
} catch {
|
|
78
|
-
console.error(`Failed to remove ${
|
|
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) !==
|
|
86
|
+
plugins = plugins.filter((p) => (typeof p === 'string' ? p : p.name) !== canonical);
|
|
83
87
|
|
|
84
|
-
console.log(`✓ Removed ${
|
|
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
|
+
});
|