mu-coding 0.2.0 → 0.5.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 +0 -2
- package/bin/mu.js +1 -1
- package/package.json +12 -4
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +40 -0
- package/src/cli/args.ts +128 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +51 -0
- package/src/config/index.ts +181 -0
- package/src/main.ts +4 -0
- package/src/runtime/createRegistry.ts +58 -0
- package/src/runtime/pluginLoader.ts +109 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +190 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- 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/{useChat.ts → chat/useChat.ts} +32 -6
- package/src/tui/chat/useChatPanel.ts +96 -0
- package/src/tui/chat/useChatSession.ts +115 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +57 -0
- package/src/tui/chat/useStatusSegments.ts +49 -0
- package/src/tui/chat/useStreamConsumer.ts +118 -0
- package/src/tui/components/chat/ChatPanel.tsx +12 -38
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +70 -0
- package/src/tui/components/messages/EditOutput.tsx +42 -27
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +26 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/messageItem.tsx +4 -15
- package/src/tui/components/messages/toolCallBlock.tsx +56 -34
- package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
- package/src/tui/components/primitives/pickerModal.tsx +45 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/statusBar.tsx +25 -0
- package/src/tui/components/ui/dialogLayer.tsx +21 -7
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
- package/src/tui/input/commands.test.ts +49 -0
- package/src/tui/input/commands.ts +39 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +88 -0
- package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +30 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -92
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -161
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/singleShot.ts +0 -42
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- 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/tui/components/{ui → primitives}/modal.tsx +0 -0
- /package/src/tui/components/{ui → primitives}/toast.tsx +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
|
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,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mu-coding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
+
"./config": "./src/config/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
9
16
|
"scripts": {
|
|
10
17
|
"dev": "bun --watch bin/mu.js",
|
|
11
|
-
"start": "bun bin/mu.js"
|
|
18
|
+
"start": "bun bin/mu.js",
|
|
19
|
+
"test": "bun test"
|
|
12
20
|
},
|
|
13
21
|
"dependencies": {
|
|
14
22
|
"ink": "^7.0.1",
|
|
15
|
-
"mu-agents": "0.
|
|
16
|
-
"mu-provider": "0.
|
|
23
|
+
"mu-agents": "0.5.0",
|
|
24
|
+
"mu-provider": "0.5.0",
|
|
17
25
|
"react": "^19.2.5"
|
|
18
26
|
}
|
|
19
27
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PluginRegistry } from 'mu-agents';
|
|
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,40 @@
|
|
|
1
|
+
import type { PluginRegistry } from 'mu-agents';
|
|
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 { renderApp } from '../tui/renderApp';
|
|
8
|
+
import { registerShutdown } from './shutdown';
|
|
9
|
+
|
|
10
|
+
async function runApp(): Promise<void> {
|
|
11
|
+
if (await handleSubcommand()) return;
|
|
12
|
+
|
|
13
|
+
const cliArgs = parseArgs();
|
|
14
|
+
const config = loadConfig(cliArgs.model);
|
|
15
|
+
const uiService = new InkUIService();
|
|
16
|
+
|
|
17
|
+
// Create the shutdown handle BEFORE the registry so we can pass it into the
|
|
18
|
+
// plugin context. The registry is bound through a thunk, filled in once
|
|
19
|
+
// construction completes.
|
|
20
|
+
let registryRef: PluginRegistry | null = null;
|
|
21
|
+
const shutdown = registerShutdown(() => registryRef);
|
|
22
|
+
|
|
23
|
+
const registry = await createRegistry({ cwd: process.cwd(), config, uiService, shutdown });
|
|
24
|
+
registryRef = registry;
|
|
25
|
+
|
|
26
|
+
renderApp({
|
|
27
|
+
config,
|
|
28
|
+
initialMessages: resolveInitialMessages(cliArgs),
|
|
29
|
+
registry,
|
|
30
|
+
uiService,
|
|
31
|
+
shutdown,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function startApp(): void {
|
|
36
|
+
runApp().catch((err) => {
|
|
37
|
+
console.error(err);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
40
|
+
}
|
package/src/cli/args.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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-provider';
|
|
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
|
+
↑ / ↓ Navigate input history
|
|
40
|
+
Ctrl+N New conversation
|
|
41
|
+
Ctrl+M Cycle models
|
|
42
|
+
Ctrl+O Model picker
|
|
43
|
+
Ctrl+V Paste image from clipboard`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printVersion(): never {
|
|
48
|
+
// Walk up from this file to find mu-coding's package.json. Works whether
|
|
49
|
+
// the file is loaded from `src/cli/args.ts` (bun --watch) or `dist/cli/args.js`.
|
|
50
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
const candidates = [join(here, '..', '..', 'package.json'), join(here, '..', 'package.json')];
|
|
52
|
+
for (const path of candidates) {
|
|
53
|
+
try {
|
|
54
|
+
const pkg = JSON.parse(readFileSync(path, 'utf-8'));
|
|
55
|
+
if (pkg?.name === 'mu-coding') {
|
|
56
|
+
console.log(`mu ${pkg.version}`);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Try next candidate.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.log('mu (version unknown)');
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseArgs(): CliArgs {
|
|
68
|
+
let parsed: ReturnType<typeof nodeParseArgs>;
|
|
69
|
+
try {
|
|
70
|
+
parsed = nodeParseArgs({
|
|
71
|
+
options: {
|
|
72
|
+
model: { type: 'string', short: 'm' },
|
|
73
|
+
continue: { type: 'boolean', short: 'c' },
|
|
74
|
+
session: { type: 'string' },
|
|
75
|
+
version: { type: 'boolean', short: 'v' },
|
|
76
|
+
help: { type: 'boolean', short: 'h' },
|
|
77
|
+
},
|
|
78
|
+
// Subcommands like `install`/`uninstall` are routed before parseArgs(),
|
|
79
|
+
// so we shouldn't see them here. Allow positionals just in case the
|
|
80
|
+
// user passes stray args (we ignore them rather than erroring).
|
|
81
|
+
allowPositionals: true,
|
|
82
|
+
strict: true,
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
86
|
+
console.error('Run `mu --help` for usage.');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (parsed.values.help) {
|
|
91
|
+
printHelp();
|
|
92
|
+
}
|
|
93
|
+
if (parsed.values.version) {
|
|
94
|
+
printVersion();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
model: typeof parsed.values.model === 'string' ? parsed.values.model : undefined,
|
|
99
|
+
continueSession: parsed.values.continue === true,
|
|
100
|
+
sessionPath: typeof parsed.values.session === 'string' ? parsed.values.session : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveInitialMessages(cliArgs: CliArgs): ChatMessage[] | undefined {
|
|
105
|
+
if (cliArgs.sessionPath) {
|
|
106
|
+
const msgs = loadSession(cliArgs.sessionPath);
|
|
107
|
+
if (msgs.length === 0) {
|
|
108
|
+
console.error(`Error: session file is empty or not found: ${cliArgs.sessionPath}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
return msgs;
|
|
112
|
+
}
|
|
113
|
+
if (cliArgs.continueSession) {
|
|
114
|
+
const latest = getLatestSession();
|
|
115
|
+
if (!latest) {
|
|
116
|
+
console.error('Error: no sessions found');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
const msgs = loadSession(latest);
|
|
120
|
+
if (msgs.length === 0) {
|
|
121
|
+
console.error('Error: latest session is empty');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
console.log(`Resuming session: ${latest}`);
|
|
125
|
+
return msgs;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
@@ -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,51 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { ProviderConfig } from 'mu-provider';
|
|
5
|
+
|
|
6
|
+
// ─── XDG Path Helpers ─────────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// Path resolution lives alongside config because the config module owns the
|
|
9
|
+
// "where do mu's files live?" question end-to-end (config.json, SYSTEM.md,
|
|
10
|
+
// sessions, plugin caches). Resolved lazily so tests can stub the env after
|
|
11
|
+
// module import; production callers pay only one `process.env` lookup per call.
|
|
12
|
+
//
|
|
13
|
+
// Other workspace packages (e.g. `mu-pi-compat`) import these via the
|
|
14
|
+
// `mu-coding/config` subpath export — see `mu-coding/package.json`.
|
|
15
|
+
|
|
16
|
+
const HOME = homedir();
|
|
17
|
+
|
|
18
|
+
export function getConfigDir(): string {
|
|
19
|
+
return process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'mu') : join(HOME, '.config', 'mu');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getDataDir(): string {
|
|
23
|
+
return process.env.XDG_DATA_HOME ? join(process.env.XDG_DATA_HOME, 'mu') : join(HOME, '.local', 'share', 'mu');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getCacheDir(): string {
|
|
27
|
+
return process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, 'mu') : join(HOME, '.cache', 'mu');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getPluginsDir(): string {
|
|
31
|
+
return join(getConfigDir(), 'plugins');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── npm: specifier parsing ───────────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// Plugin specifiers stored in `config.plugins` use the form `npm:<bare>` where
|
|
37
|
+
// `<bare>` is a bun/npm package spec — possibly versioned (`foo@1.2.3`,
|
|
38
|
+
// `@scope/foo@^1.0.0`). Multiple call sites need to split this consistently:
|
|
39
|
+
// `mu install`/`mu uninstall` use it for canonicalization & version stripping;
|
|
40
|
+
// the runtime plugin loader uses it to resolve from
|
|
41
|
+
// `~/.local/share/mu/node_modules/<name>`. Centralized here so the rules
|
|
42
|
+
// can never drift between install-time and load-time.
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parsed npm specifier: package name (always present, scope preserved) and an
|
|
46
|
+
* optional install-time version range. Examples:
|
|
47
|
+
*
|
|
48
|
+
* foo → { name: "foo" }
|
|
49
|
+
* foo@1.2.3 → { name: "foo", version: "1.2.3" }
|
|
50
|
+
* @scope/foo → { name: "@scope/foo" }
|
|
51
|
+
* @scope/foo@^1.0.0 → { name: "@scope/foo", version: "^1.0.0" }
|
|
52
|
+
*/
|
|
53
|
+
export interface ParsedNpmSpec {
|
|
54
|
+
name: string;
|
|
55
|
+
version?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Strip the leading `@` for scoped names before searching for the version `@`. */
|
|
59
|
+
export function parseBareNpmSpec(bare: string): ParsedNpmSpec {
|
|
60
|
+
const scoped = bare.startsWith('@');
|
|
61
|
+
const at = bare.indexOf('@', scoped ? 1 : 0);
|
|
62
|
+
if (at === -1) return { name: bare };
|
|
63
|
+
return { name: bare.slice(0, at), version: bare.slice(at + 1) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Canonical form stored in `config.plugins`: `npm:<package-name>` with the
|
|
68
|
+
* version stripped, so `npm:foo@1.2.3` and `npm:foo` deduplicate correctly.
|
|
69
|
+
*/
|
|
70
|
+
export function canonicalNpmSpecifier(bare: string): string {
|
|
71
|
+
return `npm:${parseBareNpmSpec(bare).name}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AppConfig extends ProviderConfig {
|
|
75
|
+
plugins?: Array<string | { name: string; config?: Record<string, unknown> }>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keys that `loadConfig`/`saveConfig` know how to materialize from `AppConfig`.
|
|
80
|
+
* Used as an allow-list when seeding a fresh config.json. On `saveConfig` we
|
|
81
|
+
* preserve every key already present in the file so users can keep custom
|
|
82
|
+
* fields (or fields added by future versions) without losing them on round-trip.
|
|
83
|
+
*/
|
|
84
|
+
const CONFIG_FILE_KEYS = [
|
|
85
|
+
'baseUrl',
|
|
86
|
+
'model',
|
|
87
|
+
'maxTokens',
|
|
88
|
+
'temperature',
|
|
89
|
+
'streamTimeoutMs',
|
|
90
|
+
'systemPrompt',
|
|
91
|
+
'plugins',
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
function configPath(): string {
|
|
95
|
+
return join(getConfigDir(), 'config.json');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function systemPromptPath(): string {
|
|
99
|
+
return join(getConfigDir(), 'SYSTEM.md');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tryRead(path: string): string | undefined {
|
|
103
|
+
try {
|
|
104
|
+
return readFileSync(path, 'utf-8').trim() || undefined;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tryParseJson(text: string | undefined): Partial<AppConfig> {
|
|
111
|
+
if (!text) {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(text);
|
|
116
|
+
} catch {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function envInt(key: string): number | undefined {
|
|
122
|
+
const v = process.env[key];
|
|
123
|
+
if (!v) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const n = Number.parseInt(v, 10);
|
|
127
|
+
return Number.isNaN(n) ? undefined : n;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function envFloat(key: string): number | undefined {
|
|
131
|
+
const v = process.env[key];
|
|
132
|
+
if (!v) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const n = Number.parseFloat(v);
|
|
136
|
+
return Number.isNaN(n) ? undefined : n;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function loadConfig(cliModel?: string): AppConfig {
|
|
140
|
+
const path = configPath();
|
|
141
|
+
const file = tryParseJson(tryRead(path));
|
|
142
|
+
|
|
143
|
+
const config: AppConfig = {
|
|
144
|
+
baseUrl: process.env.MU_BASE_URL || file.baseUrl || 'http://localhost:8080/v1',
|
|
145
|
+
model: cliModel || process.env.MU_MODEL || file.model,
|
|
146
|
+
maxTokens: envInt('MU_MAX_TOKENS') ?? file.maxTokens ?? 4096,
|
|
147
|
+
temperature: envFloat('MU_TEMPERATURE') ?? file.temperature ?? 0.7,
|
|
148
|
+
streamTimeoutMs: envInt('MU_STREAM_TIMEOUT') ?? file.streamTimeoutMs ?? 60_000,
|
|
149
|
+
systemPrompt: process.env.MU_SYSTEM_PROMPT || file.systemPrompt || tryRead(systemPromptPath()),
|
|
150
|
+
plugins: file.plugins,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (!existsSync(path)) {
|
|
154
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
155
|
+
const fileConfig = Object.fromEntries(
|
|
156
|
+
CONFIG_FILE_KEYS.filter((k) => file[k] !== undefined).map((k) => [k, file[k]]),
|
|
157
|
+
) as Partial<AppConfig>;
|
|
158
|
+
writeFileSync(path, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return config;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Persist `updates` to config.json, preserving any keys (known or unknown)
|
|
166
|
+
* that are already present in the file. Only `undefined` values are stripped.
|
|
167
|
+
*/
|
|
168
|
+
export function saveConfig(updates: Partial<AppConfig>): void {
|
|
169
|
+
const path = configPath();
|
|
170
|
+
const file = tryParseJson(tryRead(path)) as Record<string, unknown>;
|
|
171
|
+
const merged: Record<string, unknown> = { ...file };
|
|
172
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
173
|
+
if (value === undefined) {
|
|
174
|
+
delete merged[key];
|
|
175
|
+
} else {
|
|
176
|
+
merged[key] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
180
|
+
writeFileSync(path, JSON.stringify(merged, null, 2), 'utf-8');
|
|
181
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createBuiltinPlugin, PluginRegistry } from 'mu-agents';
|
|
2
|
+
import type { ShutdownFn } from '../app/shutdown';
|
|
3
|
+
import type { AppConfig } from '../config/index';
|
|
4
|
+
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
5
|
+
import { discoverPluginFiles, loadConfiguredPlugin } from './pluginLoader';
|
|
6
|
+
|
|
7
|
+
interface CreateRegistryOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
config: AppConfig;
|
|
10
|
+
uiService: InkUIService;
|
|
11
|
+
/**
|
|
12
|
+
* Host shutdown is forwarded to plugins via PluginContext so a plugin (or
|
|
13
|
+
* Pi extension via mu-pi-compat) calling shutdown gets the same graceful
|
|
14
|
+
* path as Ctrl+C — terminal restored, plugins deactivated.
|
|
15
|
+
*
|
|
16
|
+
* Optional because some callers (tests, single-shot) don't have one.
|
|
17
|
+
*/
|
|
18
|
+
shutdown?: ShutdownFn;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the plugin config object passed into a plugin's factory.
|
|
23
|
+
* Every plugin (configured or locally discovered) gets the UI service and
|
|
24
|
+
* (when available) the host shutdown so tools can prompt, toast, set status
|
|
25
|
+
* segments, or trigger graceful exit without extra plumbing.
|
|
26
|
+
*/
|
|
27
|
+
function buildPluginConfig(
|
|
28
|
+
uiService: InkUIService,
|
|
29
|
+
shutdown: ShutdownFn | undefined,
|
|
30
|
+
base?: Record<string, unknown>,
|
|
31
|
+
): Record<string, unknown> {
|
|
32
|
+
const merged: Record<string, unknown> = base ? { ...base } : {};
|
|
33
|
+
merged.ui = uiService;
|
|
34
|
+
if (shutdown) merged.shutdown = shutdown;
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createRegistry(options: CreateRegistryOptions): Promise<PluginRegistry> {
|
|
39
|
+
const { cwd, config, uiService, shutdown } = options;
|
|
40
|
+
const registry = new PluginRegistry({ cwd, config: {}, ui: uiService, shutdown });
|
|
41
|
+
|
|
42
|
+
await registry.register(createBuiltinPlugin());
|
|
43
|
+
|
|
44
|
+
// Locally dropped plugins (~/.config/mu/plugins/*.ts) go through the same
|
|
45
|
+
// loader as configured ones so they receive `{ ui, shutdown }` and get
|
|
46
|
+
// consistent error reporting.
|
|
47
|
+
for (const filePath of discoverPluginFiles()) {
|
|
48
|
+
await loadConfiguredPlugin(registry, filePath, buildPluginConfig(uiService, shutdown), uiService);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const entry of config.plugins ?? []) {
|
|
52
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
53
|
+
const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
|
|
54
|
+
await loadConfiguredPlugin(registry, name, buildPluginConfig(uiService, shutdown, pluginConfig), uiService);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return registry;
|
|
58
|
+
}
|