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.
Files changed (72) hide show
  1. package/README.md +0 -2
  2. package/bin/mu.js +1 -1
  3. package/package.json +12 -4
  4. package/src/app/shutdown.ts +94 -0
  5. package/src/app/startApp.ts +40 -0
  6. package/src/cli/args.ts +128 -0
  7. package/src/{install.ts → cli/install.ts} +19 -15
  8. package/src/config/index.test.ts +51 -0
  9. package/src/config/index.ts +181 -0
  10. package/src/main.ts +4 -0
  11. package/src/runtime/createRegistry.ts +58 -0
  12. package/src/runtime/pluginLoader.ts +109 -0
  13. package/src/sessions/index.test.ts +66 -0
  14. package/src/sessions/index.ts +190 -0
  15. package/src/sessions/peek.test.ts +88 -0
  16. package/src/sessions/project.ts +51 -0
  17. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  18. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  19. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  20. package/src/tui/chat/useAttachment.ts +74 -0
  21. package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
  22. package/src/tui/chat/useChatPanel.ts +96 -0
  23. package/src/tui/chat/useChatSession.ts +115 -0
  24. package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
  25. package/src/tui/chat/usePluginStatus.ts +44 -0
  26. package/src/tui/chat/useSessionPersistence.ts +57 -0
  27. package/src/tui/chat/useStatusSegments.ts +49 -0
  28. package/src/tui/chat/useStreamConsumer.ts +118 -0
  29. package/src/tui/components/chat/ChatPanel.tsx +12 -38
  30. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  31. package/src/tui/components/chat/Pickers.tsx +2 -2
  32. package/src/tui/components/messageView.tsx +70 -0
  33. package/src/tui/components/messages/EditOutput.tsx +42 -27
  34. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  35. package/src/tui/components/messages/ToolHeader.tsx +26 -0
  36. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  37. package/src/tui/components/messages/messageItem.tsx +4 -15
  38. package/src/tui/components/messages/toolCallBlock.tsx +56 -34
  39. package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
  40. package/src/tui/components/primitives/pickerModal.tsx +45 -0
  41. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  42. package/src/tui/components/statusBar.tsx +25 -0
  43. package/src/tui/components/ui/dialogLayer.tsx +21 -7
  44. package/src/tui/hooks/useScroll.ts +11 -3
  45. package/src/tui/input/InputBox.tsx +6 -0
  46. package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
  47. package/src/tui/input/commands.test.ts +49 -0
  48. package/src/tui/input/commands.ts +39 -0
  49. package/src/tui/input/sanitize.ts +33 -0
  50. package/src/tui/input/useCommandExecutor.ts +32 -0
  51. package/src/tui/input/useInputBox.ts +88 -0
  52. package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
  53. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  54. package/src/tui/renderApp.tsx +30 -0
  55. package/src/utils/clipboard.ts +97 -0
  56. package/src/utils/diff.test.ts +56 -0
  57. package/src/cli.ts +0 -92
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -161
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/singleShot.ts +0 -42
  64. package/src/tui/commands.ts +0 -33
  65. package/src/tui/components/chatLayout.tsx +0 -192
  66. package/src/tui/useChatSession.ts +0 -155
  67. package/src/tui/useChatUI.ts +0 -51
  68. package/tsconfig.json +0 -10
  69. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  70. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  71. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  72. /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.tsx';
2
+ import '../src/main.ts';
package/package.json CHANGED
@@ -1,19 +1,27 @@
1
1
  {
2
2
  "name": "mu-coding",
3
- "version": "0.2.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.2.0",
16
- "mu-provider": "0.2.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
+ }
@@ -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 { 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,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,4 @@
1
+ #!/usr/bin/env bun
2
+ import { startApp } from './app/startApp';
3
+
4
+ startApp();
@@ -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
+ }